diff --git a/fluxer-reaper.py b/fluxer-reaper.py index f537dd5..84b65ba 100644 --- a/fluxer-reaper.py +++ b/fluxer-reaper.py @@ -2,7 +2,7 @@ import sys import asyncio import logging from src.ui.app import run_cli -from src.config import load_config +from src.core.configuration import load_config def setup_logging(): try: diff --git a/src/core/base.py b/src/core/base.py new file mode 100644 index 0000000..8c511f2 --- /dev/null +++ b/src/core/base.py @@ -0,0 +1,74 @@ +import logging +from typing import Dict, Any + +from src.core.configuration import AppConfig +from src.core.state import MigrationState +from src.core.discord_reader import DiscordReader +from src.core.fluxer_writer import FluxerWriter + +logger = logging.getLogger(__name__) + +class MigrationContext: + """Holds state and connections for reading from Discord and writing to Fluxer.""" + + def __init__(self, config: AppConfig): + self.config = config + self.state = MigrationState() + + self.discord_reader = DiscordReader( + token=config.discord_bot_token, + server_id=config.discord_server_id + ) + + self.fluxer_writer = FluxerWriter( + token=config.fluxer_bot_token, + community_id=config.fluxer_community_id + ) + + self.is_running = False + + async def validate_all(self) -> Dict[str, Any]: + """Returns connection validation status as a dictionary.""" + try: + d_valid = await self.discord_reader.validate() + f_valid = await self.fluxer_writer.validate() + return { + "discord_token": d_valid.get("token", False), + "discord_bot_name": d_valid.get("bot_name"), + "discord_server": d_valid.get("server", False), + "discord_server_name": d_valid.get("server_name"), + "discord_intents": d_valid.get("intents", {}), + "discord_permissions": d_valid.get("permissions", {}), + "fluxer_token": f_valid.get("token", False), + "fluxer_bot_name": f_valid.get("bot_name"), + "fluxer_community": f_valid.get("community", False), + "fluxer_community_name": f_valid.get("community_name"), + "fluxer_permissions": f_valid.get("permissions", {}) + } + except Exception as e: + logger.error(f"Validation failed with exception: {e}") + return { + "discord_token": False, + "discord_server": False, + "fluxer_token": False, + "fluxer_community": False + } + + async def start_connections(self): + await self.discord_reader.start() + await self.fluxer_writer.start() + + async def start_fluxer_only(self): + """Starts only the Fluxer writer (used for Danger Zone operations that don't need Discord).""" + await self.fluxer_writer.start() + + async def close_connections(self): + await self.discord_reader.close() + await self.fluxer_writer.close() + + async def close_fluxer_only(self): + """Closes only the Fluxer writer. Pair with start_fluxer_only().""" + await self.fluxer_writer.close() + + def stop(self): + self.is_running = False diff --git a/src/core/clone_server.py b/src/core/clone_server.py new file mode 100644 index 0000000..273d743 --- /dev/null +++ b/src/core/clone_server.py @@ -0,0 +1,166 @@ +import asyncio +import logging +from typing import Callable, Awaitable + +from src.core.base import MigrationContext + +logger = logging.getLogger(__name__) + +async def sync_channel_state(context: MigrationContext): + """ + Scans Fluxer for channels matching Discord names and updates state.json mappings. + This prevents duplicate creation when the state.json is empty but channels exist in Fluxer. + """ + categories = await context.discord_reader.get_categories() + channels = await context.discord_reader.get_channels() + fluxer_channels = await context.fluxer_writer.get_channels() + + # Build name -> id map and ID set for Fluxer for fast lookup + fluxer_name_map = {c.get("name"): str(c.get("id")) for c in fluxer_channels if c.get("name")} + fluxer_id_set = {str(c.get("id")) for c in fluxer_channels} + + updates = 0 + removals = 0 + + # 1. Verify and Sync Categories + for cat in categories: + discord_id = str(cat.id) + fluxer_id = context.state.get_fluxer_category_id(discord_id) + + if fluxer_id: + if fluxer_id not in fluxer_id_set: + context.state.remove_category_mapping(discord_id) + removals += 1 + elif cat.name in fluxer_name_map: + context.state.set_category_mapping(discord_id, fluxer_name_map[cat.name]) + updates += 1 + + # 2. Verify and Sync Channels + for ch in channels: + discord_id = str(ch.id) + fluxer_id = context.state.get_fluxer_channel_id(discord_id) + + if fluxer_id: + if fluxer_id not in fluxer_id_set: + context.state.remove_channel_mapping(discord_id) + removals += 1 + elif ch.name in fluxer_name_map: + context.state.set_channel_mapping(discord_id, fluxer_name_map[ch.name]) + updates += 1 + + if updates > 0 or removals > 0: + logger.info(f"Channel sync: {updates} mapped, {removals} stale mappings removed") + + +async def migrate_channels(context: MigrationContext, progress_callback: Callable[[str, str, int, int], Awaitable[None]] | None = None, force: bool = False): + """Clones categories and text channels. + + Args: + progress_callback: Optional callback receiving (item_name, status, current, total) + force: If True, re-create channels even if they exist in state. + """ + categories = await context.discord_reader.get_categories() + channels = await context.discord_reader.get_channels() + + # 1. Identify categories to create + missing_categories = [cat for cat in categories if force or not context.state.get_fluxer_category_id(str(cat.id))] + missing_category_ids = {str(cat.id) for cat in missing_categories} + + # 2. Identify channels to create or move + # Fetch current Fluxer state to check parent_ids + fluxer_channels = await context.fluxer_writer.get_channels() + fluxer_parent_map = {str(c["id"]): (str(c.get("parent_id")) if c.get("parent_id") else None) for c in fluxer_channels} + + channels_to_create = [] + channels_to_move = [] + + for ch in channels: + discord_id = str(ch.id) + fluxer_id = context.state.get_fluxer_channel_id(discord_id) + + if force or not fluxer_id: + # We'll resolve the parent_id in the loop after categories are created + channels_to_create.append(ch) + else: + # Always add to move/sync list to ensure properties (topic, nsfw, slowmode) are synced + # even if the parent category is already correct. + channels_to_move.append((ch, fluxer_id)) + + total = len(missing_categories) + len(channels_to_create) + len(channels_to_move) + current_idx = 0 + + if total == 0: + return + + # Migrate Categories first + for cat in missing_categories: + if not context.is_running: break + + state_key = str(cat.id) + fluxer_id = await context.fluxer_writer.create_channel(cat.name, type=4) + context.state.set_category_mapping(state_key, fluxer_id) + + current_idx += 1 + if progress_callback: await progress_callback(f"Cat: {cat.name}", "Copying", current_idx, total) + await asyncio.sleep(context.config.migration.rate_limit_delay_seconds) + + # Create missing channels + for channel in channels_to_create: + if not context.is_running: break + + state_key = str(channel.id) + topic = getattr(channel, 'topic', "") or "" + nsfw = getattr(channel, 'nsfw', False) + slowmode = getattr(channel, 'slowmode_delay', 0) + + logger.debug(f"Creating channel {channel.name}: topic={topic}, nsfw={nsfw}, slowmode={slowmode}") + + parent_id = context.state.get_fluxer_category_id(str(channel.category_id)) if channel.category_id else None + + fluxer_id = await context.fluxer_writer.create_channel( + name=channel.name, + topic=topic, + type=0, + parent_id=parent_id, + nsfw=nsfw, + slowmode_delay=slowmode + ) + context.state.set_channel_mapping(state_key, fluxer_id) + + # Sync again immediately because some properties (like slowmode) are ignored on creation + await context.fluxer_writer.modify_channel( + channel_id=fluxer_id, + parent_id=parent_id, + name=channel.name, + topic=topic, + nsfw=nsfw, + slowmode_delay=slowmode + ) + + current_idx += 1 + if progress_callback: await progress_callback(channel.name, "Copying", current_idx, total) + await asyncio.sleep(context.config.migration.rate_limit_delay_seconds) + + # Move/Sync existing channels + for channel, fluxer_id in channels_to_move: + if not context.is_running: break + + parent_id = context.state.get_fluxer_category_id(str(channel.category_id)) if channel.category_id else None + nsfw = getattr(channel, 'nsfw', False) + slowmode = getattr(channel, 'slowmode_delay', 0) + topic = getattr(channel, 'topic', "") or "" + + logger.debug(f"Syncing existing channel {channel.name} ({fluxer_id}): topic={topic}, nsfw={nsfw}, slowmode={slowmode}") + + await context.fluxer_writer.modify_channel( + channel_id=fluxer_id, + parent_id=parent_id, + name=channel.name, + topic=topic, + nsfw=nsfw, + slowmode_delay=slowmode + ) + + current_idx += 1 + if progress_callback: await progress_callback(channel.name, "Syncing", current_idx, total) + await asyncio.sleep(context.config.migration.rate_limit_delay_seconds) diff --git a/src/config.py b/src/core/configuration.py similarity index 100% rename from src/config.py rename to src/core/configuration.py diff --git a/src/core/danger_zone.py b/src/core/danger_zone.py new file mode 100644 index 0000000..32c6b3d --- /dev/null +++ b/src/core/danger_zone.py @@ -0,0 +1,33 @@ +import logging +from typing import Callable, Awaitable + +from src.core.base import MigrationContext + +logger = logging.getLogger(__name__) + +async def danger_remove_logo_and_banner(context: MigrationContext) -> dict: + """Removes the community logo and banner image. Returns per-field status.""" + return await context.fluxer_writer.remove_community_logo_and_banner() + +async def danger_delete_all_channels(context: MigrationContext, progress_callback=None) -> int: + """Deletes every channel and category in the Fluxer community.""" + count = await context.fluxer_writer.delete_all_channels(progress_callback=progress_callback) + context.state.clear_channel_mappings() + context.state.clear_message_history() + return count + +async def danger_reset_channel_permissions(context: MigrationContext, progress_callback=None) -> int: + """Resets all permission overwrites on every channel and category.""" + return await context.fluxer_writer.reset_channel_permissions(progress_callback=progress_callback) + +async def danger_delete_all_roles(context: MigrationContext, progress_callback=None) -> int: + """Deletes all deletable roles (skips managed/bot roles and @everyone).""" + count = await context.fluxer_writer.delete_all_roles(progress_callback=progress_callback) + context.state.clear_role_mappings() + return count + +async def danger_delete_all_emojis_and_stickers(context: MigrationContext, progress_callback=None) -> dict: + """Deletes all custom emojis and stickers. Returns {"emojis": int, "stickers": int}.""" + counts = await context.fluxer_writer.delete_all_emojis_and_stickers(progress_callback=progress_callback) + context.state.clear_asset_mappings() + return counts diff --git a/src/discord_bot/reader.py b/src/core/discord_reader.py similarity index 100% rename from src/discord_bot/reader.py rename to src/core/discord_reader.py diff --git a/src/core/emoji_stickers.py b/src/core/emoji_stickers.py new file mode 100644 index 0000000..0b27a13 --- /dev/null +++ b/src/core/emoji_stickers.py @@ -0,0 +1,106 @@ +import asyncio +import logging +from typing import Callable, Awaitable, List + +from src.core.base import MigrationContext + +logger = logging.getLogger(__name__) + +async def sync_assets_state(context: MigrationContext): + """ + Scans Fluxer for emojis and stickers matching Discord names and updates state.json mappings. + """ + discord_emojis = await context.discord_reader.get_emojis() + discord_stickers = await context.discord_reader.get_stickers() + + fluxer_emojis = await context.fluxer_writer.client.get_guild_emojis(context.config.fluxer_community_id) + fluxer_stickers = await context.fluxer_writer.client.get_guild_stickers(context.config.fluxer_community_id) + + # Build name -> id maps and ID sets for Fluxer for fast lookup + fluxer_emoji_map = {e.get("name"): str(e.get("id")) for e in fluxer_emojis if e.get("name")} + fluxer_sticker_map = {s.get("name"): str(s.get("id")) for s in fluxer_stickers if s.get("name")} + fluxer_emoji_ids = {str(e.get("id")) for e in fluxer_emojis} + fluxer_sticker_ids = {str(s.get("id")) for s in fluxer_stickers} + + updates = 0 + removals = 0 + + # 1. Verify and Sync Emojis + for emoji in discord_emojis: + discord_id = str(emoji.id) + fluxer_id = context.state.get_fluxer_emoji_id(discord_id) + + if fluxer_id: + if fluxer_id not in fluxer_emoji_ids: + context.state.remove_emoji_mapping(discord_id) + removals += 1 + elif emoji.name in fluxer_emoji_map: + context.state.set_emoji_mapping(discord_id, fluxer_emoji_map[emoji.name]) + updates += 1 + + # 2. Verify and Sync Stickers + for sticker in discord_stickers: + discord_id = str(sticker.id) + fluxer_id = context.state.get_fluxer_sticker_id(discord_id) + + if fluxer_id: + if fluxer_id not in fluxer_sticker_ids: + context.state.remove_sticker_mapping(discord_id) + removals += 1 + elif sticker.name in fluxer_sticker_map: + context.state.set_sticker_mapping(discord_id, fluxer_sticker_map[sticker.name]) + updates += 1 + + if updates > 0 or removals > 0: + logger.info(f"Asset sync: {updates} mapped, {removals} stale mappings removed") + + +async def migrate_emojis(context: MigrationContext, progress_callback: Callable[[str, str, int, int], Awaitable[None]] | None = None, types_to_include: List[str] = ["Emoji", "Sticker"], force: bool = False): + """Copies custom emojis and stickers. + + Args: + force: If True, skip state cache and re-copy even if already migrated. + """ + objs = [] + if "Emoji" in types_to_include: + emojis = await context.discord_reader.get_emojis() + objs.extend([(e, "Emoji") for e in emojis]) + if "Sticker" in types_to_include: + stickers = await context.discord_reader.get_stickers() + objs.extend([(s, "Sticker") for s in stickers]) + + if not force: + objs = [(obj, obj_type) for obj, obj_type in objs if not ( + context.state.get_fluxer_emoji_id(str(obj.id)) if obj_type == "Emoji" else context.state.get_fluxer_sticker_id(str(obj.id)) + )] + + total = len(objs) + + if total == 0: + return + + for idx, (obj, obj_type) in enumerate(objs): + if not context.is_running: break + + try: + if obj_type == "Emoji": + img_data = await context.discord_reader.download_emoji(obj) + fluxer_id = await context.fluxer_writer.create_emoji( + name=obj.name, + image_bytes=img_data + ) + if fluxer_id: + context.state.set_emoji_mapping(str(obj.id), fluxer_id) + else: + img_data = await context.discord_reader.download_sticker(obj) + fluxer_id = await context.fluxer_writer.create_sticker( + name=obj.name, + image_bytes=img_data + ) + if fluxer_id: + context.state.set_sticker_mapping(str(obj.id), fluxer_id) + except Exception as e: + logger.error(f"Error downloading/uploading {obj_type.lower()} {obj.name}: {e}") + + if progress_callback: await progress_callback(obj.name, obj_type, idx + 1, total) + await asyncio.sleep(context.config.migration.rate_limit_delay_seconds) diff --git a/src/core/engine.py b/src/core/engine.py deleted file mode 100644 index 8c4cd79..0000000 --- a/src/core/engine.py +++ /dev/null @@ -1,662 +0,0 @@ -import asyncio -import logging -from typing import Callable, Awaitable, List, Dict, Any -from src.config import AppConfig -from src.core.state import MigrationState -from src.discord_bot.reader import DiscordReader -from src.fluxer_bot.writer import FluxerWriter - -logger = logging.getLogger(__name__) - -class MigrationEngine: - """Orchestrates reading from Discord and writing to Fluxer.""" - - def __init__(self, config: AppConfig): - self.config = config - self.state = MigrationState() - - self.discord_reader = DiscordReader( - token=config.discord_bot_token, - server_id=config.discord_server_id - ) - - self.fluxer_writer = FluxerWriter( - token=config.fluxer_bot_token, - community_id=config.fluxer_community_id - ) - - self.is_running = False - - async def validate_all(self) -> Dict[str, Any]: - """Returns True if both connections are valid.""" - try: - d_valid = await self.discord_reader.validate() - f_valid = await self.fluxer_writer.validate() - return { - "discord_token": d_valid.get("token", False), - "discord_bot_name": d_valid.get("bot_name"), - "discord_server": d_valid.get("server", False), - "discord_server_name": d_valid.get("server_name"), - "discord_intents": d_valid.get("intents", {}), - "discord_permissions": d_valid.get("permissions", {}), - "fluxer_token": f_valid.get("token", False), - "fluxer_bot_name": f_valid.get("bot_name"), - "fluxer_community": f_valid.get("community", False), - "fluxer_community_name": f_valid.get("community_name"), - "fluxer_permissions": f_valid.get("permissions", {}) - } - except Exception as e: - logger.error(f"Validation failed with exception: {e}") - return { - "discord_token": False, - "discord_server": False, - "fluxer_token": False, - "fluxer_community": False - } - - async def start_connections(self): - await self.discord_reader.start() - await self.fluxer_writer.start() - - async def start_fluxer_only(self): - """Starts only the Fluxer writer (used for Danger Zone operations that don't need Discord).""" - await self.fluxer_writer.start() - - async def close_connections(self): - await self.discord_reader.close() - await self.fluxer_writer.close() - - async def close_fluxer_only(self): - """Closes only the Fluxer writer. Pair with start_fluxer_only().""" - await self.fluxer_writer.close() - - async def sync_server_metadata(self, progress_callback: Callable[[str, str], Awaitable[None]], components: List[str] = ["name", "icon", "banner"]): - """Syncs the server name, logo and banner.""" - metadata = await self.discord_reader.get_server_metadata() - - # 1. Sync Name - if "name" in components: - try: - name = metadata.get("name") - await self.fluxer_writer.update_guild_metadata(name=name) - await progress_callback("Server Name", "DONE") - except Exception: - await progress_callback("Server Name", "ERROR") - - # 2. Sync Icon - if "icon" in components: - try: - icon_bytes = None - if self.discord_reader.guild and self.discord_reader.guild.icon: - icon_bytes = await self.discord_reader.download_asset(self.discord_reader.guild.icon) - - if icon_bytes: - await self.fluxer_writer.update_guild_metadata(icon=icon_bytes) - await progress_callback("Server Icon", "DONE") - else: - await progress_callback("Server Icon", "SKIP") - except Exception: - await progress_callback("Server Icon", "ERROR") - - # 3. Sync Banner - if "banner" in components: - try: - banner_bytes = None - if self.discord_reader.guild and self.discord_reader.guild.banner: - banner_bytes = await self.discord_reader.download_asset(self.discord_reader.guild.banner) - - if banner_bytes: - await self.fluxer_writer.update_guild_metadata(banner=banner_bytes) - await progress_callback("Server Banner", "DONE") - else: - await progress_callback("Server Banner", "SKIP") - except Exception: - await progress_callback("Server Banner", "ERROR") - - async def sync_channel_state(self): - """ - Scans Fluxer for channels matching Discord names and updates state.json mappings. - This prevents duplicate creation when the state.json is empty but channels exist in Fluxer. - """ - categories = await self.discord_reader.get_categories() - channels = await self.discord_reader.get_channels() - fluxer_channels = await self.fluxer_writer.get_channels() - - # Build name -> id map and ID set for Fluxer for fast lookup - fluxer_name_map = {c.get("name"): str(c.get("id")) for c in fluxer_channels if c.get("name")} - fluxer_id_set = {str(c.get("id")) for c in fluxer_channels} - - updates = 0 - removals = 0 - - # 1. Verify and Sync Categories - for cat in categories: - discord_id = str(cat.id) - fluxer_id = self.state.get_fluxer_category_id(discord_id) - - if fluxer_id: - if fluxer_id not in fluxer_id_set: - self.state.remove_category_mapping(discord_id) - removals += 1 - elif cat.name in fluxer_name_map: - self.state.set_category_mapping(discord_id, fluxer_name_map[cat.name]) - updates += 1 - - # 2. Verify and Sync Channels - for ch in channels: - discord_id = str(ch.id) - fluxer_id = self.state.get_fluxer_channel_id(discord_id) - - if fluxer_id: - if fluxer_id not in fluxer_id_set: - self.state.remove_channel_mapping(discord_id) - removals += 1 - elif ch.name in fluxer_name_map: - self.state.set_channel_mapping(discord_id, fluxer_name_map[ch.name]) - updates += 1 - - if updates > 0 or removals > 0: - logger.info(f"Channel sync: {updates} mapped, {removals} stale mappings removed") - - async def sync_roles_state(self): - """ - Scans Fluxer for roles matching Discord names and updates state.json mappings. - """ - discord_roles = await self.discord_reader.get_roles() - fluxer_roles = await self.fluxer_writer.client.get_guild_roles(self.config.fluxer_community_id) - - # Build name -> id maps and ID sets for Fluxer for fast lookup - fluxer_role_map = {r.get("name"): str(r.get("id")) for r in fluxer_roles if r.get("name")} - fluxer_role_ids = {str(r.get("id")) for r in fluxer_roles} - - updates = 0 - removals = 0 - - # Verify and Sync Roles - for role in discord_roles: - discord_id = str(role.id) - fluxer_id = self.state.get_fluxer_role_id(discord_id) - - if fluxer_id: - if fluxer_id not in fluxer_role_ids: - self.state.remove_role_mapping(discord_id) - removals += 1 - elif role.name in fluxer_role_map: - self.state.set_role_mapping(discord_id, fluxer_role_map[role.name]) - updates += 1 - - if updates > 0 or removals > 0: - logger.info(f"Role sync: {updates} mapped, {removals} stale mappings removed") - - async def sync_assets_state(self): - """ - Scans Fluxer for emojis and stickers matching Discord names and updates state.json mappings. - """ - discord_emojis = await self.discord_reader.get_emojis() - discord_stickers = await self.discord_reader.get_stickers() - - fluxer_emojis = await self.fluxer_writer.client.get_guild_emojis(self.config.fluxer_community_id) - fluxer_stickers = await self.fluxer_writer.client.get_guild_stickers(self.config.fluxer_community_id) - - # Build name -> id maps and ID sets for Fluxer for fast lookup - fluxer_emoji_map = {e.get("name"): str(e.get("id")) for e in fluxer_emojis if e.get("name")} - fluxer_sticker_map = {s.get("name"): str(s.get("id")) for s in fluxer_stickers if s.get("name")} - fluxer_emoji_ids = {str(e.get("id")) for e in fluxer_emojis} - fluxer_sticker_ids = {str(s.get("id")) for s in fluxer_stickers} - - updates = 0 - removals = 0 - - # 1. Verify and Sync Emojis - for emoji in discord_emojis: - discord_id = str(emoji.id) - fluxer_id = self.state.get_fluxer_emoji_id(discord_id) - - if fluxer_id: - if fluxer_id not in fluxer_emoji_ids: - self.state.remove_emoji_mapping(discord_id) - removals += 1 - elif emoji.name in fluxer_emoji_map: - self.state.set_emoji_mapping(discord_id, fluxer_emoji_map[emoji.name]) - updates += 1 - - # 2. Verify and Sync Stickers - for sticker in discord_stickers: - discord_id = str(sticker.id) - fluxer_id = self.state.get_fluxer_sticker_id(discord_id) - - if fluxer_id: - if fluxer_id not in fluxer_sticker_ids: - self.state.remove_sticker_mapping(discord_id) - removals += 1 - elif sticker.name in fluxer_sticker_map: - self.state.set_sticker_mapping(discord_id, fluxer_sticker_map[sticker.name]) - updates += 1 - - if updates > 0 or removals > 0: - logger.info(f"Asset sync: {updates} mapped, {removals} stale mappings removed") - - async def migrate_channels(self, progress_callback: Callable[[str, str, int, int], Awaitable[None]] | None = None, force: bool = False): - """Clones categories and text channels. - - Args: - progress_callback: Optional callback receiving (item_name, status, current, total) - force: If True, re-create channels even if they exist in state. - """ - categories = await self.discord_reader.get_categories() - channels = await self.discord_reader.get_channels() - - # 1. Identify categories to create - missing_categories = [cat for cat in categories if force or not self.state.get_fluxer_category_id(str(cat.id))] - missing_category_ids = {str(cat.id) for cat in missing_categories} - - # 2. Identify channels to create or move - # Fetch current Fluxer state to check parent_ids - fluxer_channels = await self.fluxer_writer.get_channels() - fluxer_parent_map = {str(c["id"]): (str(c.get("parent_id")) if c.get("parent_id") else None) for c in fluxer_channels} - - channels_to_create = [] - channels_to_move = [] - - for ch in channels: - discord_id = str(ch.id) - fluxer_id = self.state.get_fluxer_channel_id(discord_id) - - if force or not fluxer_id: - # We'll resolve the parent_id in the loop after categories are created - channels_to_create.append(ch) - else: - # Always add to move/sync list to ensure properties (topic, nsfw, slowmode) are synced - # even if the parent category is already correct. - channels_to_move.append((ch, fluxer_id)) - - total = len(missing_categories) + len(channels_to_create) + len(channels_to_move) - current_idx = 0 - - if total == 0: - return - - # Migrate Categories first - for cat in missing_categories: - if not self.is_running: break - - state_key = str(cat.id) - fluxer_id = await self.fluxer_writer.create_channel(cat.name, type=4) - self.state.set_category_mapping(state_key, fluxer_id) - - current_idx += 1 - if progress_callback: await progress_callback(f"Cat: {cat.name}", "Copying", current_idx, total) - await asyncio.sleep(self.config.migration.rate_limit_delay_seconds) - - # Create missing channels - for channel in channels_to_create: - if not self.is_running: break - - state_key = str(channel.id) - topic = getattr(channel, 'topic', "") or "" - nsfw = getattr(channel, 'nsfw', False) - slowmode = getattr(channel, 'slowmode_delay', 0) - - logger.debug(f"Creating channel {channel.name}: topic={topic}, nsfw={nsfw}, slowmode={slowmode}") - - parent_id = self.state.get_fluxer_category_id(str(channel.category_id)) if channel.category_id else None - - fluxer_id = await self.fluxer_writer.create_channel( - name=channel.name, - topic=topic, - type=0, - parent_id=parent_id, - nsfw=nsfw, - slowmode_delay=slowmode - ) - self.state.set_channel_mapping(state_key, fluxer_id) - - # Sync again immediately because some properties (like slowmode) are ignored on creation - await self.fluxer_writer.modify_channel( - channel_id=fluxer_id, - parent_id=parent_id, - name=channel.name, - topic=topic, - nsfw=nsfw, - slowmode_delay=slowmode - ) - - current_idx += 1 - if progress_callback: await progress_callback(channel.name, "Copying", current_idx, total) - await asyncio.sleep(self.config.migration.rate_limit_delay_seconds) - - # Move/Sync existing channels - for channel, fluxer_id in channels_to_move: - if not self.is_running: break - - parent_id = self.state.get_fluxer_category_id(str(channel.category_id)) if channel.category_id else None - nsfw = getattr(channel, 'nsfw', False) - slowmode = getattr(channel, 'slowmode_delay', 0) - topic = getattr(channel, 'topic', "") or "" - - logger.debug(f"Syncing existing channel {channel.name} ({fluxer_id}): topic={topic}, nsfw={nsfw}, slowmode={slowmode}") - - await self.fluxer_writer.modify_channel( - channel_id=fluxer_id, - parent_id=parent_id, - name=channel.name, - topic=topic, - nsfw=nsfw, - slowmode_delay=slowmode - ) - - current_idx += 1 - if progress_callback: await progress_callback(channel.name, "Syncing", current_idx, total) - await asyncio.sleep(self.config.migration.rate_limit_delay_seconds) - - async def sync_permissions(self, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None): - """Syncs category and channel role overrides/permissions.""" - categories = await self.discord_reader.get_categories() - channels = await self.discord_reader.get_channels() - - # Only sync for items that are already mapped - categories = [c for c in categories if self.state.get_fluxer_category_id(str(c.id))] - channels = [c for c in channels if self.state.get_fluxer_channel_id(str(c.id))] - - total = len(categories) + len(channels) - current_idx = 0 - - if total == 0: - return - - async def _sync_overwrites(discord_item, fluxer_id): - """Helper to sync role overwrites for a given channel or category.""" - for target, overwrite in discord_item.overwrites.items(): - if type(target).__name__ == "Role": - discord_role_id = str(target.id) - # Handle @everyone role special case - if discord_role_id == self.config.discord_server_id: - fluxer_role_id = self.config.fluxer_community_id - else: - fluxer_role_id = self.state.get_fluxer_role_id(discord_role_id) - - if not fluxer_role_id: - continue - - allow_val, deny_val = overwrite.pair() - await self.fluxer_writer.set_channel_permission( - channel_id=fluxer_id, - overwrite_id=fluxer_role_id, - allow=allow_val.value, - deny=deny_val.value, - is_role=True - ) - - # Sync Category Permissions (Role Overwrites) - for cat in categories: - if not self.is_running: break - fluxer_id = self.state.get_fluxer_category_id(str(cat.id)) - if fluxer_id: - try: - await _sync_overwrites(cat, fluxer_id) - except Exception as e: - logger.error(f"Failed syncing permissions for category {cat.name}: {e}") - - current_idx += 1 - if progress_callback: await progress_callback(f"Cat: {cat.name}", current_idx, total) - - # Sync Channel Permissions - for channel in channels: - if not self.is_running: break - fluxer_id = self.state.get_fluxer_channel_id(str(channel.id)) - if fluxer_id: - try: - await _sync_overwrites(channel, fluxer_id) - except Exception as e: - logger.error(f"Failed syncing permissions for channel {channel.name}: {e}") - - current_idx += 1 - if progress_callback: await progress_callback(channel.name, current_idx, total) - - async def analyze_migration(self, source_channel_id: int, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None) -> Dict[str, int]: - """ - Scans channel history to count messages, threads, and attachments. - """ - stats = {"messages": 0, "threads": 0, "attachments": 0} - - async for msg in self.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id): - if not self.is_running: - break - - stats["messages"] += 1 - stats["attachments"] += len(msg.attachments) - - # Count thread messages and markers - if hasattr(msg, 'thread') and msg.thread: - stats["threads"] += 1 - # Recursively count thread content - thread_stats = await self.analyze_migration(msg.thread.id) - stats["messages"] += thread_stats["messages"] - stats["attachments"] += thread_stats["attachments"] - stats["threads"] += thread_stats["threads"] # Nested threads (rare in Discord but possible in forum channels) - - if progress_callback and stats["messages"] % 10 == 0: - await progress_callback(stats["messages"]) - - return stats - - async def migrate_messages(self, source_channel_id: int, target_channel_id: str, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None): - """Migrate messages for a specific channel.""" - message_count = 0 - async for msg in self.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id): - if not self.is_running: - break - - # Process attachments - files = [] - attachments_to_process = list(msg.attachments) - - # Check if this message is forwarded - # Discord flags: forwarded (is bit 28 / 0x10000000) - is_forwarded = False - if hasattr(msg.flags, 'forwarded'): - is_forwarded = msg.flags.forwarded - - # If forwarded, the content and attachments might be in message_snapshots (discord.py 2.5+) - content = msg.content - if is_forwarded: - logger.debug(f"Detected forwarded message: ID={msg.id}, Flags={msg.flags.value}") - if hasattr(msg, 'message_snapshots') and msg.message_snapshots: - # For now we handle the first snapshot - snapshot = msg.message_snapshots[0] - if not content: - content = snapshot.content - # Add snapshot attachments to the list to process - attachments_to_process.extend(snapshot.attachments) - logger.debug(f"Found forwarded snapshot content: {content[:50]}... and {len(snapshot.attachments)} attachments") - - for att in attachments_to_process: - try: - att_data = await self.discord_reader.download_attachment(att) - files.append({"filename": att.filename, "data": att_data}) - except Exception as e: - logger.error(f"Failed to download attachment {att.filename}: {e}") - - try: - # Check if this message is a reply - reply_to_fluxer_id = None - if msg.reference and msg.reference.message_id: - reply_to_fluxer_id = self.state.get_fluxer_message_id(str(msg.reference.message_id)) - - fluxer_msg_id = await self.fluxer_writer.send_message( - channel_id=target_channel_id, - author_name=msg.author.display_name, - author_avatar_url=str(msg.author.display_avatar.url), - content=content, - timestamp=msg.created_at.strftime("%Y-%m-%d %H:%M:%S"), - files=files if files else None, - reply_to_message_id=reply_to_fluxer_id, - is_forwarded=is_forwarded - ) - - if fluxer_msg_id: - self.state.set_message_mapping(str(msg.id), fluxer_msg_id) - - # Check for associated thread - if hasattr(msg, 'thread') and msg.thread: - thread = msg.thread - logger.info(f"Detected thread '{thread.name}' on message {msg.id}") - - # Send Start Marker - await self.fluxer_writer.send_marker( - channel_id=target_channel_id, - content=f"> <<< THREAD: **{thread.name}** >>>" - ) - - # Migrate thread messages - # We don't pass a progress callback here to avoid confusing the UI - # but we do want to track count if possible. - await self.migrate_messages( - source_channel_id=thread.id, - target_channel_id=target_channel_id - ) - - # Send End Marker - await self.fluxer_writer.send_marker( - channel_id=target_channel_id, - content=f"> <<< END OF THREAD >>>" - ) - - self.state.update_last_message_timestamp(str(source_channel_id), str(msg.created_at)) - message_count += 1 - if progress_callback: - await progress_callback(message_count) - except Exception as e: - logger.error(f"Failed to process message {msg.id}: {e}") - import traceback - logger.error(traceback.format_exc()) - - # Delay for rate limit safety - await asyncio.sleep(self.config.migration.rate_limit_delay_seconds) - - return message_count - - async def migrate_roles(self, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None, force: bool = False): - """Copies roles and their baseline permissions.""" - roles = await self.discord_reader.get_roles() - - if not force: - roles = [r for r in roles if not self.state.get_fluxer_role_id(str(r.id))] - - total = len(roles) - - if total == 0: - return - - for idx, role in enumerate(roles): - if not self.is_running: break - - fluxer_id = await self.fluxer_writer.create_role( - name=role.name, - color=role.color.value, - hoist=role.hoist, - mentionable=role.mentionable - ) - if fluxer_id: - self.state.set_role_mapping(str(role.id), fluxer_id) - - if progress_callback: await progress_callback(role.name, idx + 1, total) - await asyncio.sleep(self.config.migration.rate_limit_delay_seconds) - - async def migrate_emojis(self, progress_callback: Callable[[str, str, int, int], Awaitable[None]] | None = None, types_to_include: List[str] = ["Emoji", "Sticker"], force: bool = False): - """Copies custom emojis and stickers. - - Args: - force: If True, skip state cache and re-copy even if already migrated. - """ - objs = [] - if "Emoji" in types_to_include: - emojis = await self.discord_reader.get_emojis() - objs.extend([(e, "Emoji") for e in emojis]) - if "Sticker" in types_to_include: - stickers = await self.discord_reader.get_stickers() - objs.extend([(s, "Sticker") for s in stickers]) - - if not force: - objs = [(obj, obj_type) for obj, obj_type in objs if not ( - self.state.get_fluxer_emoji_id(str(obj.id)) if obj_type == "Emoji" else self.state.get_fluxer_sticker_id(str(obj.id)) - )] - - total = len(objs) - - if total == 0: - return - - for idx, (obj, obj_type) in enumerate(objs): - if not self.is_running: break - - try: - if obj_type == "Emoji": - img_data = await self.discord_reader.download_emoji(obj) - fluxer_id = await self.fluxer_writer.create_emoji( - name=obj.name, - image_bytes=img_data - ) - if fluxer_id: - self.state.set_emoji_mapping(str(obj.id), fluxer_id) - else: - img_data = await self.discord_reader.download_sticker(obj) - fluxer_id = await self.fluxer_writer.create_sticker( - name=obj.name, - image_bytes=img_data - ) - if fluxer_id: - self.state.set_sticker_mapping(str(obj.id), fluxer_id) - except Exception as e: - logger.error(f"Error downloading/uploading {obj_type.lower()} {obj.name}: {e}") - - if progress_callback: await progress_callback(obj.name, obj_type, idx + 1, total) - await asyncio.sleep(self.config.migration.rate_limit_delay_seconds) - - async def run_full_migration(self): - self.is_running = True - try: - await self.start_connections() - await self.migrate_channels() - - # Example: just migrate one channel's messages to test - channels = await self.discord_reader.get_channels() - if channels: - await self.migrate_messages(channels[0].id) - - finally: - await self.close_connections() - self.is_running = False - - def stop(self): - self.is_running = False - - # ──────────────── DANGER ZONE ──────────────── - - async def danger_remove_logo_and_banner(self) -> dict: - """Removes the community logo and banner image. Returns per-field status.""" - return await self.fluxer_writer.remove_community_logo_and_banner() - - async def danger_delete_all_channels(self, progress_callback=None) -> int: - """Deletes every channel and category in the Fluxer community.""" - count = await self.fluxer_writer.delete_all_channels(progress_callback=progress_callback) - self.state.clear_channel_mappings() - self.state.clear_message_history() - return count - - async def danger_reset_channel_permissions(self, progress_callback=None) -> int: - """Resets all permission overwrites on every channel and category.""" - return await self.fluxer_writer.reset_channel_permissions(progress_callback=progress_callback) - - async def danger_delete_all_roles(self, progress_callback=None) -> int: - """Deletes all deletable roles (skips managed/bot roles and @everyone).""" - count = await self.fluxer_writer.delete_all_roles(progress_callback=progress_callback) - self.state.clear_role_mappings() - return count - - async def danger_delete_all_emojis_and_stickers(self, progress_callback=None) -> dict: - """Deletes all custom emojis and stickers. Returns {"emojis": int, "stickers": int}.""" - counts = await self.fluxer_writer.delete_all_emojis_and_stickers(progress_callback=progress_callback) - self.state.clear_asset_mappings() - return counts - diff --git a/src/fluxer_bot/writer.py b/src/core/fluxer_writer.py similarity index 99% rename from src/fluxer_bot/writer.py rename to src/core/fluxer_writer.py index f2d0517..3b2723f 100644 --- a/src/fluxer_bot/writer.py +++ b/src/core/fluxer_writer.py @@ -246,7 +246,7 @@ class FluxerWriter: if webhook and not files and not reply_to_message_id: msg = await webhook.send( content=final_content, - username=f"{author_name} (via Discord)", + username=f"{author_name} (discord)", avatar_url=author_avatar_url, wait=True ) diff --git a/src/core/migrate_message.py b/src/core/migrate_message.py new file mode 100644 index 0000000..9ae48d1 --- /dev/null +++ b/src/core/migrate_message.py @@ -0,0 +1,132 @@ +import asyncio +import logging +from typing import Callable, Awaitable, Dict + +from src.core.base import MigrationContext + +logger = logging.getLogger(__name__) + +async def analyze_migration(context: MigrationContext, source_channel_id: int, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None) -> Dict[str, int]: + """ + Scans channel history to count messages, threads, and attachments. + """ + stats = {"messages": 0, "threads": 0, "attachments": 0} + + async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id): + if not context.is_running: + break + + stats["messages"] += 1 + stats["attachments"] += len(msg.attachments) + + # Count thread messages and markers + if hasattr(msg, 'thread') and msg.thread: + stats["threads"] += 1 + # Recursively count thread content + thread_stats = await analyze_migration(context, msg.thread.id) + stats["messages"] += thread_stats["messages"] + stats["attachments"] += thread_stats["attachments"] + stats["threads"] += thread_stats["threads"] # Nested threads (rare in Discord but possible in forum channels) + + if progress_callback and stats["messages"] % 10 == 0: + await progress_callback(stats["messages"]) + + return stats + + +async def migrate_messages(context: MigrationContext, source_channel_id: int, target_channel_id: str, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None): + """Migrate messages for a specific channel.""" + message_count = 0 + async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id): + if not context.is_running: + break + + # Process attachments + files = [] + attachments_to_process = list(msg.attachments) + + # Check if this message is forwarded + # Discord flags: forwarded (is bit 28 / 0x10000000) + is_forwarded = False + if hasattr(msg.flags, 'forwarded'): + is_forwarded = msg.flags.forwarded + + # If forwarded, the content and attachments might be in message_snapshots (discord.py 2.5+) + content = msg.content + if is_forwarded: + logger.debug(f"Detected forwarded message: ID={msg.id}, Flags={msg.flags.value}") + if hasattr(msg, 'message_snapshots') and msg.message_snapshots: + # For now we handle the first snapshot + snapshot = msg.message_snapshots[0] + if not content: + content = snapshot.content + # Add snapshot attachments to the list to process + attachments_to_process.extend(snapshot.attachments) + logger.debug(f"Found forwarded snapshot content: {content[:50]}... and {len(snapshot.attachments)} attachments") + + for att in attachments_to_process: + try: + att_data = await context.discord_reader.download_attachment(att) + files.append({"filename": att.filename, "data": att_data}) + except Exception as e: + logger.error(f"Failed to download attachment {att.filename}: {e}") + + try: + # Check if this message is a reply + reply_to_fluxer_id = None + if msg.reference and msg.reference.message_id: + reply_to_fluxer_id = context.state.get_fluxer_message_id(str(msg.reference.message_id)) + + fluxer_msg_id = await context.fluxer_writer.send_message( + channel_id=target_channel_id, + author_name=msg.author.display_name, + author_avatar_url=str(msg.author.display_avatar.url), + content=content, + timestamp=msg.created_at.strftime("%Y-%m-%d %H:%M:%S"), + files=files if files else None, + reply_to_message_id=reply_to_fluxer_id, + is_forwarded=is_forwarded + ) + + if fluxer_msg_id: + context.state.set_message_mapping(str(msg.id), fluxer_msg_id) + + # Check for associated thread + if hasattr(msg, 'thread') and msg.thread: + thread = msg.thread + logger.info(f"Detected thread '{thread.name}' on message {msg.id}") + + # Send Start Marker + await context.fluxer_writer.send_marker( + channel_id=target_channel_id, + content=f"> <<< THREAD: **{thread.name}** >>>" + ) + + # Migrate thread messages + # We don't pass a progress callback here to avoid confusing the UI + # but we do want to track count if possible. + await migrate_messages( + context=context, + source_channel_id=thread.id, + target_channel_id=target_channel_id + ) + + # Send End Marker + await context.fluxer_writer.send_marker( + channel_id=target_channel_id, + content=f"> <<< END OF THREAD >>>" + ) + + context.state.update_last_message_timestamp(str(source_channel_id), str(msg.created_at)) + message_count += 1 + if progress_callback: + await progress_callback(message_count) + except Exception as e: + logger.error(f"Failed to process message {msg.id}: {e}") + import traceback + logger.error(traceback.format_exc()) + + # Delay for rate limit safety + await asyncio.sleep(context.config.migration.rate_limit_delay_seconds) + + return message_count diff --git a/src/core/roles_permissions.py b/src/core/roles_permissions.py new file mode 100644 index 0000000..5399e45 --- /dev/null +++ b/src/core/roles_permissions.py @@ -0,0 +1,130 @@ +import asyncio +import logging +from typing import Callable, Awaitable + +from src.core.base import MigrationContext + +logger = logging.getLogger(__name__) + +async def sync_roles_state(context: MigrationContext): + """ + Scans Fluxer for roles matching Discord names and updates state.json mappings. + """ + discord_roles = await context.discord_reader.get_roles() + fluxer_roles = await context.fluxer_writer.client.get_guild_roles(context.config.fluxer_community_id) + + # Build name -> id maps and ID sets for Fluxer for fast lookup + fluxer_role_map = {r.get("name"): str(r.get("id")) for r in fluxer_roles if r.get("name")} + fluxer_role_ids = {str(r.get("id")) for r in fluxer_roles} + + updates = 0 + removals = 0 + + # Verify and Sync Roles + for role in discord_roles: + discord_id = str(role.id) + fluxer_id = context.state.get_fluxer_role_id(discord_id) + + if fluxer_id: + if fluxer_id not in fluxer_role_ids: + context.state.remove_role_mapping(discord_id) + removals += 1 + elif role.name in fluxer_role_map: + context.state.set_role_mapping(discord_id, fluxer_role_map[role.name]) + updates += 1 + + if updates > 0 or removals > 0: + logger.info(f"Role sync: {updates} mapped, {removals} stale mappings removed") + + +async def sync_permissions(context: MigrationContext, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None): + """Syncs category and channel role overrides/permissions.""" + categories = await context.discord_reader.get_categories() + channels = await context.discord_reader.get_channels() + + # Only sync for items that are already mapped + categories = [c for c in categories if context.state.get_fluxer_category_id(str(c.id))] + channels = [c for c in channels if context.state.get_fluxer_channel_id(str(c.id))] + + total = len(categories) + len(channels) + current_idx = 0 + + if total == 0: + return + + async def _sync_overwrites(discord_item, fluxer_id): + """Helper to sync role overwrites for a given channel or category.""" + for target, overwrite in discord_item.overwrites.items(): + if type(target).__name__ == "Role": + discord_role_id = str(target.id) + # Handle @everyone role special case + if discord_role_id == context.config.discord_server_id: + fluxer_role_id = context.config.fluxer_community_id + else: + fluxer_role_id = context.state.get_fluxer_role_id(discord_role_id) + + if not fluxer_role_id: + continue + + allow_val, deny_val = overwrite.pair() + await context.fluxer_writer.set_channel_permission( + channel_id=fluxer_id, + overwrite_id=fluxer_role_id, + allow=allow_val.value, + deny=deny_val.value, + is_role=True + ) + + # Sync Category Permissions (Role Overwrites) + for cat in categories: + if not context.is_running: break + fluxer_id = context.state.get_fluxer_category_id(str(cat.id)) + if fluxer_id: + try: + await _sync_overwrites(cat, fluxer_id) + except Exception as e: + logger.error(f"Failed syncing permissions for category {cat.name}: {e}") + + current_idx += 1 + if progress_callback: await progress_callback(f"Cat: {cat.name}", current_idx, total) + + # Sync Channel Permissions + for channel in channels: + if not context.is_running: break + fluxer_id = context.state.get_fluxer_channel_id(str(channel.id)) + if fluxer_id: + try: + await _sync_overwrites(channel, fluxer_id) + except Exception as e: + logger.error(f"Failed syncing permissions for channel {channel.name}: {e}") + + current_idx += 1 + if progress_callback: await progress_callback(channel.name, current_idx, total) + + +async def migrate_roles(context: MigrationContext, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None, force: bool = False): + """Copies roles and their baseline permissions.""" + roles = await context.discord_reader.get_roles() + + if not force: + roles = [r for r in roles if not context.state.get_fluxer_role_id(str(r.id))] + + total = len(roles) + + if total == 0: + return + + for idx, role in enumerate(roles): + if not context.is_running: break + + fluxer_id = await context.fluxer_writer.create_role( + name=role.name, + color=role.color.value, + hoist=role.hoist, + mentionable=role.mentionable + ) + if fluxer_id: + context.state.set_role_mapping(str(role.id), fluxer_id) + + if progress_callback: await progress_callback(role.name, idx + 1, total) + await asyncio.sleep(context.config.migration.rate_limit_delay_seconds) diff --git a/src/core/server_metadata.py b/src/core/server_metadata.py new file mode 100644 index 0000000..103e0e3 --- /dev/null +++ b/src/core/server_metadata.py @@ -0,0 +1,49 @@ +import logging +from typing import Callable, Awaitable, List + +from src.core.base import MigrationContext + +logger = logging.getLogger(__name__) + +async def sync_server_metadata(context: MigrationContext, progress_callback: Callable[[str, str], Awaitable[None]], components: List[str] = ["name", "icon", "banner"]): + """Syncs the server name, logo and banner.""" + metadata = await context.discord_reader.get_server_metadata() + + # 1. Sync Name + if "name" in components: + try: + name = metadata.get("name") + await context.fluxer_writer.update_guild_metadata(name=name) + await progress_callback("Server Name", "DONE") + except Exception: + await progress_callback("Server Name", "ERROR") + + # 2. Sync Icon + if "icon" in components: + try: + icon_bytes = None + if context.discord_reader.guild and context.discord_reader.guild.icon: + icon_bytes = await context.discord_reader.download_asset(context.discord_reader.guild.icon) + + if icon_bytes: + await context.fluxer_writer.update_guild_metadata(icon=icon_bytes) + await progress_callback("Server Icon", "DONE") + else: + await progress_callback("Server Icon", "SKIP") + except Exception: + await progress_callback("Server Icon", "ERROR") + + # 3. Sync Banner + if "banner" in components: + try: + banner_bytes = None + if context.discord_reader.guild and context.discord_reader.guild.banner: + banner_bytes = await context.discord_reader.download_asset(context.discord_reader.guild.banner) + + if banner_bytes: + await context.fluxer_writer.update_guild_metadata(banner=banner_bytes) + await progress_callback("Server Banner", "DONE") + else: + await progress_callback("Server Banner", "SKIP") + except Exception: + await progress_callback("Server Banner", "ERROR") diff --git a/src/discord_bot/__init__.py b/src/discord_bot/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/fluxer_bot/__init__.py b/src/fluxer_bot/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/ui/app.py b/src/ui/app.py index a38debd..d1ef000 100644 --- a/src/ui/app.py +++ b/src/ui/app.py @@ -7,8 +7,14 @@ from rich.prompt import Prompt, Confirm from rich.table import Table from rich.panel import Panel from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn -from src.config import load_config, save_config -from src.core.engine import MigrationEngine +from src.core.configuration import load_config, save_config +from src.core.base import MigrationContext +from src.core.clone_server import sync_channel_state, migrate_channels +from src.core.roles_permissions import sync_roles_state, sync_permissions, migrate_roles +from src.core.emoji_stickers import sync_assets_state, migrate_emojis +from src.core.server_metadata import sync_server_metadata +from src.core.migrate_message import analyze_migration, migrate_messages +from src.core.danger_zone import danger_remove_logo_and_banner, danger_delete_all_channels, danger_reset_channel_permissions, danger_delete_all_roles, danger_delete_all_emojis_and_stickers class RateLimitHandler(logging.Handler): """Intersects library logs to print clean rate limit messages.""" @@ -55,7 +61,7 @@ class MigrationCLI: console.print(f"[bold red]Failed to load config: {e}[/bold red]") sys.exit(1) - self.engine = MigrationEngine(self.config) + self.engine = MigrationContext(self.config) self.progress_callback_task = None self.tokens_valid = False @@ -284,7 +290,7 @@ class MigrationCLI: save_config(self.config) # Recreate engine with new config - self.engine = MigrationEngine(self.config) + self.engine = MigrationContext(self.config) # Re-validate console.print("[yellow]Validating new configuration...[/yellow]") @@ -309,7 +315,7 @@ class MigrationCLI: try: await self.engine.start_connections() with console.status("[yellow]Syncing Fluxer channel state...[/yellow]"): - await self.engine.sync_channel_state() + await sync_channel_state(self.engine) categories = await self.engine.discord_reader.get_categories() channels = await self.engine.discord_reader.get_channels() except Exception as e: @@ -408,7 +414,7 @@ class MigrationCLI: progress.update(channel_task, total=total, completed=current, description=f"[{color}]{status} Channel: {item_name}") self.engine.is_running = True - await self.engine.migrate_channels(progress_callback=update_progress, force=force) + await migrate_channels(self.engine, progress_callback=update_progress, force=force) console.print("[bold green]Server Template cloned![/bold green]") @@ -434,7 +440,7 @@ class MigrationCLI: await self.engine.start_connections() with console.status("[yellow]Checking Fluxer for existing roles...[/yellow]"): - await self.engine.sync_roles_state() + await sync_roles_state(self.engine) roles = await self.engine.discord_reader.get_roles() @@ -492,7 +498,7 @@ class MigrationCLI: progress.update(role_task, total=total, completed=current, description=f"[cyan]Syncing Role: {item_name}") self.engine.is_running = True - await self.engine.migrate_roles(progress_callback=update_progress, force=force) + await migrate_roles(self.engine, progress_callback=update_progress, force=force) console.print("[bold green]Role migration complete![/bold green]") @@ -538,7 +544,7 @@ class MigrationCLI: progress.update(perm_task, total=total, completed=current, description=f"[cyan]Syncing: {item_name}") self.engine.is_running = True - await self.engine.sync_permissions(progress_callback=update_progress) + await sync_permissions(self.engine, progress_callback=update_progress) console.print("[bold green]Permission synchronization complete![/bold green]") @@ -554,7 +560,7 @@ class MigrationCLI: await self.engine.start_connections() with console.status("[yellow]Checking Fluxer for existing emojis and stickers...[/yellow]"): - await self.engine.sync_assets_state() + await sync_assets_state(self.engine) emojis = await self.engine.discord_reader.get_emojis() stickers = await self.engine.discord_reader.get_stickers() @@ -642,7 +648,8 @@ class MigrationCLI: progress.update(emoji_task, total=total, completed=current, description=f"[cyan]Copying {item_type}: {item_name}") self.engine.is_running = True - await self.engine.migrate_emojis( + await migrate_emojis( + self.engine, progress_callback=update_progress, types_to_include=types_to_include, force=force @@ -706,7 +713,7 @@ class MigrationCLI: color = "green" if status == "DONE" else "red" if status == "ERROR" else "yellow" console.print(f"{item} [[bold {color}]{status}[/bold {color}]]") - await self.engine.sync_server_metadata(progress_callback, components=components) + await sync_server_metadata(self.engine, progress_callback, components=components) console.print("[bold green]Server metadata sync finished![/bold green]") except Exception as e: console.print(f"[bold red]Error during metadata sync: {str(e)}[/bold red]") @@ -876,7 +883,8 @@ class MigrationCLI: async def update_scan_progress(count: int): progress.update(task, description=f"[cyan]Scanned {count} items...") - stats = await self.engine.analyze_migration( + stats = await analyze_migration( + self.engine, source_channel_id=source_channel.id, after_message_id=after_id, progress_callback=update_scan_progress @@ -928,7 +936,8 @@ class MigrationCLI: async def update_msg_progress(count: int): progress.update(task, description=f"[cyan]Migrated {count} messages...") - count = await self.engine.migrate_messages( + count = await migrate_messages( + self.engine, source_channel_id=source_channel.id, target_channel_id=target_channel.get("id"), after_message_id=after_id, @@ -990,7 +999,7 @@ class MigrationCLI: progress.update(del_task, total=total, completed=current, description=f"[red]Deleting: {name}") - count = await self.engine.danger_delete_all_channels(progress_callback=on_channel_deleted) + count = await danger_delete_all_channels(self.engine, progress_callback=on_channel_deleted) console.print(f"[bold green]{count} channels/categories deleted.[/bold green]") console.print("[bold green]Done.[/bold green]") except Exception as e: @@ -1021,7 +1030,7 @@ class MigrationCLI: progress.update(perm_task, total=total, completed=current, description=f"[red]Resetting: {name}") - count = await self.engine.danger_reset_channel_permissions(progress_callback=on_perm_reset) + count = await danger_reset_channel_permissions(self.engine, progress_callback=on_perm_reset) console.print(f"[bold green]Permissions reset on {count} channels/categories.[/bold green]") console.print("[bold green]Done.[/bold green]") except Exception as e: @@ -1053,7 +1062,7 @@ class MigrationCLI: progress.update(role_task, total=total, completed=current, description=f"[red]Deleting role: {name}") - count = await self.engine.danger_delete_all_roles(progress_callback=on_role_deleted) + count = await danger_delete_all_roles(self.engine, progress_callback=on_role_deleted) console.print(f"[bold green]{count} roles deleted.[/bold green]") console.print("[bold green]Done.[/bold green]") except Exception as e: @@ -1084,7 +1093,7 @@ class MigrationCLI: progress.update(asset_task, total=total, completed=current, description=f"[red]Deleting {asset_type}: {name}") - counts = await self.engine.danger_delete_all_emojis_and_stickers(progress_callback=on_asset_deleted) + counts = await danger_delete_all_emojis_and_stickers(self.engine, progress_callback=on_asset_deleted) console.print(f"[bold green]{counts.get('emojis', 0)} emojis deleted.[/bold green]") console.print(f"[bold green]{counts.get('stickers', 0)} stickers deleted.[/bold green]") console.print("[bold green]Done.[/bold green]")