diff --git a/src/core/base.py b/src/core/base.py index 99cbad6..819295b 100644 --- a/src/core/base.py +++ b/src/core/base.py @@ -15,7 +15,10 @@ class MigrationContext: def __init__(self, config: AppConfig, target_platform: str = "fluxer"): self.config = config self.target_platform = target_platform - self.state = MigrationState() + self.state = MigrationState( + state_file=f"{target_platform}.state.json", + messages_file=f"{target_platform}.messages.json" + ) self.discord_reader = DiscordReader( token=config.discord_bot_token, diff --git a/src/core/state.py b/src/core/state.py index b745303..b72117d 100644 --- a/src/core/state.py +++ b/src/core/state.py @@ -159,6 +159,44 @@ class MigrationState: self.sticker_map.pop(str(discord_id), None) self.save_state() + # --- Generic Aliases for target platform migration --- + + get_target_channel_id = get_fluxer_channel_id + set_channel_mapping = set_channel_mapping # already generic enough in name if we ignore the 'fluxer' in implementation + + def set_target_channel_mapping(self, discord_id: str, target_id: str): + self.set_channel_mapping(discord_id, target_id) + + def get_target_category_id(self, discord_id: str) -> str | None: + return self.get_fluxer_category_id(discord_id) + + def set_target_category_mapping(self, discord_id: str, target_id: str): + self.set_category_mapping(discord_id, target_id) + + def get_target_role_id(self, discord_id: str) -> str | None: + return self.get_fluxer_role_id(discord_id) + + def set_target_role_mapping(self, discord_id: str, target_id: str): + self.set_role_mapping(discord_id, target_id) + + def get_target_emoji_id(self, discord_id: str) -> str | None: + return self.get_fluxer_emoji_id(discord_id) + + def set_target_emoji_mapping(self, discord_id: str, target_id: str): + self.set_emoji_mapping(discord_id, target_id) + + def get_target_sticker_id(self, discord_id: str) -> str | None: + return self.get_fluxer_sticker_id(discord_id) + + def set_target_sticker_mapping(self, discord_id: str, target_id: str): + self.set_sticker_mapping(discord_id, target_id) + + def get_target_message_id(self, discord_id: str) -> str | None: + return self.get_fluxer_message_id(discord_id) + + def set_target_message_mapping(self, discord_id: str, target_id: str): + self.set_message_mapping(discord_id, target_id) + # --- Message Management --- def set_message_mapping(self, discord_id: str, fluxer_id: str): diff --git a/src/fluxer/danger_zone.py b/src/fluxer/danger_zone.py index 32c6b3d..39e5bbd 100644 --- a/src/fluxer/danger_zone.py +++ b/src/fluxer/danger_zone.py @@ -6,28 +6,28 @@ 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() + """Removes the target community's logo and banner image. Returns per-field status.""" + return await context.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) + """Deletes every channel and category in the target community.""" + count = await context.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) + """Resets all permission overwrites on every channel and category in the target community.""" + return await context.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) + """Deletes all deletable roles in the target community.""" + count = await context.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) + """Deletes all custom emojis and stickers from the target community. Returns {"emojis": int, "stickers": int}.""" + counts = await context.writer.delete_all_emojis_and_stickers(progress_callback=progress_callback) context.state.clear_asset_mappings() return counts diff --git a/src/fluxer/server_metadata.py b/src/fluxer/server_metadata.py index 8836002..307aac4 100644 --- a/src/fluxer/server_metadata.py +++ b/src/fluxer/server_metadata.py @@ -14,7 +14,7 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal if "name" in components: try: name = metadata.get("name") - await context.fluxer_writer.update_guild_metadata(name=name) + await context.writer.update_guild_metadata(name=name) cloned_data["name"] = name await progress_callback("Server Name", "DONE") except Exception: @@ -28,7 +28,7 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal 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 context.writer.update_guild_metadata(icon=icon_bytes) cloned_data["icon"] = icon_bytes await progress_callback("Server Icon", "DONE") else: @@ -44,7 +44,7 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal 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 context.writer.update_guild_metadata(banner=banner_bytes) cloned_data["banner"] = banner_bytes await progress_callback("Server Banner", "DONE") else: diff --git a/src/stoat/emoji_stickers.py b/src/stoat/emoji_stickers.py new file mode 100644 index 0000000..486f5a9 --- /dev/null +++ b/src/stoat/emoji_stickers.py @@ -0,0 +1,99 @@ +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 Stoat for emojis matching Discord names and updates stoat.state.json mappings. + """ + discord_emojis = await context.discord_reader.get_emojis() + # Stickers not supported on Stoat based on current library investigation + + # StoatWriter should have a way to fetch emojis + # For now we use the client directly if it starts + if not hasattr(context.writer, 'client'): + await context.writer.start() + + server = await context.writer.client.fetch_server(context.writer.community_id) + stoat_emojis = await server.fetch_emojis() + + # Build name -> id maps and ID sets for Stoat for fast lookup + stoat_emoji_map = {e.name: str(e.id) for e in stoat_emojis if e.name} + stoat_emoji_ids = {str(e.id) for e in stoat_emojis} + + updates = 0 + removals = 0 + + # 1. Verify and Sync Emojis + for emoji in discord_emojis: + discord_id = str(emoji.id) + # Using target mapping in state + stoat_id = context.state.get_target_emoji_id(discord_id) + + if stoat_id: + if stoat_id not in stoat_emoji_ids: + context.state.emoji_map.pop(discord_id, None) + context.state.save_state() + removals += 1 + elif emoji.name in stoat_emoji_map: + context.state.set_target_emoji_mapping(discord_id, stoat_emoji_map[emoji.name]) + updates += 1 + + if updates > 0 or removals > 0: + logger.info(f"Stoat 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) -> dict[str, dict[str, str]]: + """Copies custom emojis. + + Args: + force: If True, skip state cache and re-copy even if already migrated. + + Returns: + A dictionary containing dicts of cloned asset names to their new IDs by type: {"Emoji": {"name": "id", ...}, "Sticker": {}} + """ + objs = [] + if "Emoji" in types_to_include: + emojis = await context.discord_reader.get_emojis() + objs.extend([(e, "Emoji") for e in emojis]) + + # Stickers are skipped for Stoat + + if not force: + objs = [(obj, obj_type) for obj, obj_type in objs if not ( + context.state.get_target_emoji_id(str(obj.id)) if obj_type == "Emoji" else None + )] + + total = len(objs) + cloned_assets: dict[str, dict[str, str]] = {"Emoji": {}, "Sticker": {}} + + if total == 0: + return cloned_assets + + 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) + stoat_id = await context.writer.create_emoji( + name=obj.name, + image_bytes=img_data + ) + if stoat_id: + context.state.set_target_emoji_mapping(str(obj.id), stoat_id) + cloned_assets["Emoji"][obj.name] = stoat_id + else: + # Sticker not supported + pass + 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) + + return cloned_assets diff --git a/src/stoat/writer.py b/src/stoat/writer.py index cf12d7d..5b2e75f 100644 --- a/src/stoat/writer.py +++ b/src/stoat/writer.py @@ -11,6 +11,14 @@ class StoatWriter: async def start(self): self.client = stoat.Client(token=self.token, bot=True) + # We don't fetch the server here to avoid slowing down startup if not needed + # but we might want a helper to get it + self._server = None + + async def _get_server(self): + if not self._server: + self._server = await self.client.fetch_server(self.community_id) + return self._server async def validate(self) -> dict: results = { @@ -103,14 +111,28 @@ class StoatWriter: async def create_role(self, **kwargs) -> str: return "dummy_stoat_role_id" - async def create_emoji(self, **kwargs) -> str: - return "dummy_stoat_emoji_id" + async def create_emoji(self, name: str, image_bytes: bytes, **kwargs) -> str: + server = await self._get_server() + try: + emoji = await server.create_server_emoji(name=name, image=image_bytes) + return str(emoji.id) + except Exception as e: + logger.error(f"Failed to create Stoat emoji {name}: {e}") + return "" async def create_sticker(self, **kwargs) -> str: return "dummy_stoat_sticker_id" - async def update_guild_metadata(self, **kwargs) -> None: - pass + async def update_guild_metadata(self, name: Optional[str] = None, icon: Optional[bytes] = None, banner: Optional[bytes] = None, **kwargs) -> None: + server = await self._get_server() + try: + await server.edit( + name=name if name is not None else stoat.UNDEFINED, + icon=icon if icon is not None else stoat.UNDEFINED, + banner=banner if banner is not None else stoat.UNDEFINED + ) + except Exception as e: + logger.error(f"Failed to update Stoat guild metadata: {e}") async def remove_community_logo_and_banner(self) -> dict: return {"icon": "SKIP", "banner": "SKIP"} @@ -125,7 +147,17 @@ class StoatWriter: return 0 async def delete_all_emojis_and_stickers(self, **kwargs) -> dict: - return {"emojis": 0, "stickers": 0} + server = await self._get_server() + emojis = await server.fetch_emojis() + count = 0 + for emoji in emojis: + try: + await emoji.delete() + count += 1 + except Exception as e: + logger.error(f"Failed to delete Stoat emoji {emoji.name}: {e}") + + return {"emojis": count, "stickers": 0} async def close(self): pass diff --git a/src/ui/app.py b/src/ui/app.py index a5f19d0..99f06d6 100644 --- a/src/ui/app.py +++ b/src/ui/app.py @@ -11,7 +11,8 @@ from src.core.configuration import load_config, save_config from src.core.base import MigrationContext from src.fluxer.clone_server import sync_channel_state, migrate_channels from src.fluxer.roles_permissions import sync_roles_state, sync_permissions, migrate_roles -from src.fluxer.emoji_stickers import sync_assets_state, migrate_emojis +import src.fluxer.emoji_stickers as fluxer_emoji_stickers +import src.stoat.emoji_stickers as stoat_emoji_stickers from src.fluxer.server_metadata import sync_server_metadata from src.fluxer.migrate_message import analyze_migration, migrate_messages from src.fluxer.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 @@ -368,7 +369,7 @@ class MigrationCLI: console.print(f"Stoat Bot Token {get_status_str(self.validation_results.get('stoat_token', False), self.validation_results.get('stoat_bot_name'))}") console.print(f"Stoat Server ID {get_status_str(self.validation_results.get('stoat_server', False), self.validation_results.get('stoat_server_name'))}") - console.print("\n(1) Edit tokens") + console.print("\n(1) Edit Bot tokens & Community IDs") console.print("(2) Edit API url (for self hosted instances)") console.print("(B) Back") @@ -721,11 +722,15 @@ class MigrationCLI: async def copy_emojis(self): console.print("\n[yellow]Fetching emojis and stickers...[/yellow]") + + # Select module based on platform + asset_mod = stoat_emoji_stickers if self.target_platform == "stoat" else fluxer_emoji_stickers + try: await self.engine.start_connections() - with console.status("[yellow]Checking Fluxer for existing emojis and stickers...[/yellow]"): - await sync_assets_state(self.engine) + with console.status(f"[yellow]Checking {self.target_platform.capitalize()} for existing emojis and stickers...[/yellow]"): + await asset_mod.sync_assets_state(self.engine) emojis = await self.engine.discord_reader.get_emojis() stickers = await self.engine.discord_reader.get_stickers() @@ -737,14 +742,14 @@ class MigrationCLI: cached_emojis = 0 for e in emojis: - already = self.engine.state.get_fluxer_emoji_id(str(e.id)) + already = self.engine.state.get_target_emoji_id(str(e.id)) status = "[dim]already copied[/dim]" if already else "[bold green]NEW[/bold green]" if already: cached_emojis += 1 table.add_row("Emoji", e.name, status) cached_stickers = 0 for s in stickers: - already = self.engine.state.get_fluxer_sticker_id(str(s.id)) + already = self.engine.state.get_target_sticker_id(str(s.id)) status = "[dim]already copied[/dim]" if already else "[bold green]NEW[/bold green]" if already: cached_stickers += 1 table.add_row("Sticker", s.name, status) @@ -779,9 +784,9 @@ class MigrationCLI: # Ask about force re-copy only if there are cached items in scope cached_in_scope = 0 if "Emoji" in types_to_include: - cached_in_scope += sum(1 for e in emojis if self.engine.state.get_fluxer_emoji_id(str(e.id))) + cached_in_scope += sum(1 for e in emojis if self.engine.state.get_target_emoji_id(str(e.id))) if "Sticker" in types_to_include: - cached_in_scope += sum(1 for s in stickers if self.engine.state.get_fluxer_sticker_id(str(s.id))) + cached_in_scope += sum(1 for s in stickers if self.engine.state.get_target_sticker_id(str(s.id))) force = False if cached_in_scope > 0: @@ -813,7 +818,7 @@ class MigrationCLI: progress.update(emoji_task, total=total, completed=current, description=f"[cyan]Copying {item_type}: {item_name}") self.engine.is_running = True - cloned_assets = await migrate_emojis( + cloned_assets = await asset_mod.migrate_emojis( self.engine, progress_callback=update_progress, types_to_include=types_to_include, @@ -1245,11 +1250,11 @@ class MigrationCLI: await self.engine.close_connections() async def danger_zone(self): - """Danger Zone – irreversible destructive operations on the Fluxer community.""" + """Danger Zone – irreversible destructive operations on the target community.""" console.print("") console.print(Panel.fit( "[bold red]\u26a0 DANGER ZONE \u26a0[/bold red]\n" - "[yellow]These actions are PERMANENT and IRREVERSIBLE on your Fluxer community.[/yellow]\n" + f"[yellow]These actions are PERMANENT and IRREVERSIBLE on your {self.target_platform.capitalize()} community.[/yellow]\n" "[yellow]Always double-check before confirming.[/yellow]", style="bold red" )) @@ -1272,8 +1277,8 @@ class MigrationCLI: # ---- (1) Delete all Channels & Categories ---- if choice == "1": console.print("") - community_name = self.validation_results.get("fluxer_community_name", "Unknown") - console.print(f"[bold red]This will DELETE every channel and category in the Fluxer community: \"{community_name}\"[/bold red]") + community_name = self.validation_results.get(f"{self.target_platform}_community_name", "Unknown") + console.print(f"[bold red]This will DELETE every channel and category in the {self.target_platform.capitalize()} community: \"{community_name}\"[/bold red]") if not Confirm.ask("[bold red]Are you absolutely sure?[/bold red]"): return if not Confirm.ask("[bold red]Last chance \u2013 this cannot be undone. Continue?[/bold red]"): @@ -1305,8 +1310,8 @@ class MigrationCLI: # ---- (2) Reset Channel & Category Permissions ---- elif choice == "2": console.print("") - community_name = self.validation_results.get("fluxer_community_name", "Unknown") - console.print(f"[bold red]This will RESET all permission overwrites on every channel and category in the Fluxer community: \"{community_name}\"[/bold red]") + community_name = self.validation_results.get(f"{self.target_platform}_community_name", "Unknown") + console.print(f"[bold red]This will RESET all permission overwrites on every channel and category in the {self.target_platform.capitalize()} community: \"{community_name}\"[/bold red]") if not Confirm.ask("[bold red]Are you absolutely sure?[/bold red]"): return if not Confirm.ask("[bold red]Last chance \u2013 all custom permission overrides will be wiped. Continue?[/bold red]"): @@ -1338,8 +1343,8 @@ class MigrationCLI: # ---- (3) Delete all Roles ---- elif choice == "3": console.print("") - community_name = self.validation_results.get("fluxer_community_name", "Unknown") - console.print(f"[bold red]This will DELETE all roles in the Fluxer community: \"{community_name}\"[/bold red]") + community_name = self.validation_results.get(f"{self.target_platform}_community_name", "Unknown") + console.print(f"[bold red]This will DELETE all roles in the {self.target_platform.capitalize()} community: \"{community_name}\"[/bold red]") console.print("[dim]Managed roles (including the bot's own role) and @everyone are automatically protected.[/dim]") if not Confirm.ask("[bold red]Are you absolutely sure?[/bold red]"): return @@ -1372,8 +1377,8 @@ class MigrationCLI: # ---- (4) Delete all Emojis & Stickers ---- elif choice == "4": console.print("") - community_name = self.validation_results.get("fluxer_community_name", "Unknown") - console.print(f"[bold red]This will DELETE all custom emojis and stickers in the Fluxer community: \"{community_name}\"[/bold red]") + community_name = self.validation_results.get(f"{self.target_platform}_community_name", "Unknown") + console.print(f"[bold red]This will DELETE all custom emojis and stickers in the {self.target_platform.capitalize()} community: \"{community_name}\"[/bold red]") if not Confirm.ask("[bold red]Are you absolutely sure?[/bold red]"): return if not Confirm.ask("[bold red]Last chance \u2013 this cannot be undone. Continue?[/bold red]"):