implement emoji sync

This commit is contained in:
rambros 2026-02-24 23:52:10 +05:30
parent 75f7b8ecf9
commit 17d18bdb1e
7 changed files with 215 additions and 38 deletions

View file

@ -15,7 +15,10 @@ class MigrationContext:
def __init__(self, config: AppConfig, target_platform: str = "fluxer"): def __init__(self, config: AppConfig, target_platform: str = "fluxer"):
self.config = config self.config = config
self.target_platform = target_platform 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( self.discord_reader = DiscordReader(
token=config.discord_bot_token, token=config.discord_bot_token,

View file

@ -159,6 +159,44 @@ class MigrationState:
self.sticker_map.pop(str(discord_id), None) self.sticker_map.pop(str(discord_id), None)
self.save_state() 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 --- # --- Message Management ---
def set_message_mapping(self, discord_id: str, fluxer_id: str): def set_message_mapping(self, discord_id: str, fluxer_id: str):

View file

@ -6,28 +6,28 @@ from src.core.base import MigrationContext
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def danger_remove_logo_and_banner(context: MigrationContext) -> dict: async def danger_remove_logo_and_banner(context: MigrationContext) -> dict:
"""Removes the community logo and banner image. Returns per-field status.""" """Removes the target community's logo and banner image. Returns per-field status."""
return await context.fluxer_writer.remove_community_logo_and_banner() return await context.writer.remove_community_logo_and_banner()
async def danger_delete_all_channels(context: MigrationContext, progress_callback=None) -> int: async def danger_delete_all_channels(context: MigrationContext, progress_callback=None) -> int:
"""Deletes every channel and category in the Fluxer community.""" """Deletes every channel and category in the target community."""
count = await context.fluxer_writer.delete_all_channels(progress_callback=progress_callback) count = await context.writer.delete_all_channels(progress_callback=progress_callback)
context.state.clear_channel_mappings() context.state.clear_channel_mappings()
context.state.clear_message_history() context.state.clear_message_history()
return count return count
async def danger_reset_channel_permissions(context: MigrationContext, progress_callback=None) -> int: async def danger_reset_channel_permissions(context: MigrationContext, progress_callback=None) -> int:
"""Resets all permission overwrites on every channel and category.""" """Resets all permission overwrites on every channel and category in the target community."""
return await context.fluxer_writer.reset_channel_permissions(progress_callback=progress_callback) return await context.writer.reset_channel_permissions(progress_callback=progress_callback)
async def danger_delete_all_roles(context: MigrationContext, progress_callback=None) -> int: async def danger_delete_all_roles(context: MigrationContext, progress_callback=None) -> int:
"""Deletes all deletable roles (skips managed/bot roles and @everyone).""" """Deletes all deletable roles in the target community."""
count = await context.fluxer_writer.delete_all_roles(progress_callback=progress_callback) count = await context.writer.delete_all_roles(progress_callback=progress_callback)
context.state.clear_role_mappings() context.state.clear_role_mappings()
return count return count
async def danger_delete_all_emojis_and_stickers(context: MigrationContext, progress_callback=None) -> dict: 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}.""" """Deletes all custom emojis and stickers from the target community. Returns {"emojis": int, "stickers": int}."""
counts = await context.fluxer_writer.delete_all_emojis_and_stickers(progress_callback=progress_callback) counts = await context.writer.delete_all_emojis_and_stickers(progress_callback=progress_callback)
context.state.clear_asset_mappings() context.state.clear_asset_mappings()
return counts return counts

View file

@ -14,7 +14,7 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal
if "name" in components: if "name" in components:
try: try:
name = metadata.get("name") 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 cloned_data["name"] = name
await progress_callback("Server Name", "DONE") await progress_callback("Server Name", "DONE")
except Exception: 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) icon_bytes = await context.discord_reader.download_asset(context.discord_reader.guild.icon)
if icon_bytes: 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 cloned_data["icon"] = icon_bytes
await progress_callback("Server Icon", "DONE") await progress_callback("Server Icon", "DONE")
else: 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) banner_bytes = await context.discord_reader.download_asset(context.discord_reader.guild.banner)
if banner_bytes: 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 cloned_data["banner"] = banner_bytes
await progress_callback("Server Banner", "DONE") await progress_callback("Server Banner", "DONE")
else: else:

View file

@ -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

View file

@ -11,6 +11,14 @@ class StoatWriter:
async def start(self): async def start(self):
self.client = stoat.Client(token=self.token, bot=True) 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: async def validate(self) -> dict:
results = { results = {
@ -103,14 +111,28 @@ class StoatWriter:
async def create_role(self, **kwargs) -> str: async def create_role(self, **kwargs) -> str:
return "dummy_stoat_role_id" return "dummy_stoat_role_id"
async def create_emoji(self, **kwargs) -> str: async def create_emoji(self, name: str, image_bytes: bytes, **kwargs) -> str:
return "dummy_stoat_emoji_id" 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: async def create_sticker(self, **kwargs) -> str:
return "dummy_stoat_sticker_id" return "dummy_stoat_sticker_id"
async def update_guild_metadata(self, **kwargs) -> None: async def update_guild_metadata(self, name: Optional[str] = None, icon: Optional[bytes] = None, banner: Optional[bytes] = None, **kwargs) -> None:
pass 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: async def remove_community_logo_and_banner(self) -> dict:
return {"icon": "SKIP", "banner": "SKIP"} return {"icon": "SKIP", "banner": "SKIP"}
@ -125,7 +147,17 @@ class StoatWriter:
return 0 return 0
async def delete_all_emojis_and_stickers(self, **kwargs) -> dict: 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): async def close(self):
pass pass

View file

@ -11,7 +11,8 @@ from src.core.configuration import load_config, save_config
from src.core.base import MigrationContext from src.core.base import MigrationContext
from src.fluxer.clone_server import sync_channel_state, migrate_channels 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.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.server_metadata import sync_server_metadata
from src.fluxer.migrate_message import analyze_migration, migrate_messages 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 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 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(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("(2) Edit API url (for self hosted instances)")
console.print("(B) Back") console.print("(B) Back")
@ -721,11 +722,15 @@ class MigrationCLI:
async def copy_emojis(self): async def copy_emojis(self):
console.print("\n[yellow]Fetching emojis and stickers...[/yellow]") 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: try:
await self.engine.start_connections() await self.engine.start_connections()
with console.status("[yellow]Checking Fluxer for existing emojis and stickers...[/yellow]"): with console.status(f"[yellow]Checking {self.target_platform.capitalize()} for existing emojis and stickers...[/yellow]"):
await sync_assets_state(self.engine) await asset_mod.sync_assets_state(self.engine)
emojis = await self.engine.discord_reader.get_emojis() emojis = await self.engine.discord_reader.get_emojis()
stickers = await self.engine.discord_reader.get_stickers() stickers = await self.engine.discord_reader.get_stickers()
@ -737,14 +742,14 @@ class MigrationCLI:
cached_emojis = 0 cached_emojis = 0
for e in emojis: 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]" status = "[dim]already copied[/dim]" if already else "[bold green]NEW[/bold green]"
if already: cached_emojis += 1 if already: cached_emojis += 1
table.add_row("Emoji", e.name, status) table.add_row("Emoji", e.name, status)
cached_stickers = 0 cached_stickers = 0
for s in stickers: 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]" status = "[dim]already copied[/dim]" if already else "[bold green]NEW[/bold green]"
if already: cached_stickers += 1 if already: cached_stickers += 1
table.add_row("Sticker", s.name, status) 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 # Ask about force re-copy only if there are cached items in scope
cached_in_scope = 0 cached_in_scope = 0
if "Emoji" in types_to_include: 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: 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 force = False
if cached_in_scope > 0: 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}") progress.update(emoji_task, total=total, completed=current, description=f"[cyan]Copying {item_type}: {item_name}")
self.engine.is_running = True self.engine.is_running = True
cloned_assets = await migrate_emojis( cloned_assets = await asset_mod.migrate_emojis(
self.engine, self.engine,
progress_callback=update_progress, progress_callback=update_progress,
types_to_include=types_to_include, types_to_include=types_to_include,
@ -1245,11 +1250,11 @@ class MigrationCLI:
await self.engine.close_connections() await self.engine.close_connections()
async def danger_zone(self): 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("")
console.print(Panel.fit( console.print(Panel.fit(
"[bold red]\u26a0 DANGER ZONE \u26a0[/bold red]\n" "[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]", "[yellow]Always double-check before confirming.[/yellow]",
style="bold red" style="bold red"
)) ))
@ -1272,8 +1277,8 @@ class MigrationCLI:
# ---- (1) Delete all Channels & Categories ---- # ---- (1) Delete all Channels & Categories ----
if choice == "1": if choice == "1":
console.print("") console.print("")
community_name = self.validation_results.get("fluxer_community_name", "Unknown") 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 Fluxer community: \"{community_name}\"[/bold red]") 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]"): if not Confirm.ask("[bold red]Are you absolutely sure?[/bold red]"):
return return
if not Confirm.ask("[bold red]Last chance \u2013 this cannot be undone. Continue?[/bold red]"): 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 ---- # ---- (2) Reset Channel & Category Permissions ----
elif choice == "2": elif choice == "2":
console.print("") console.print("")
community_name = self.validation_results.get("fluxer_community_name", "Unknown") 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 Fluxer community: \"{community_name}\"[/bold red]") 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]"): if not Confirm.ask("[bold red]Are you absolutely sure?[/bold red]"):
return return
if not Confirm.ask("[bold red]Last chance \u2013 all custom permission overrides will be wiped. Continue?[/bold red]"): 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 ---- # ---- (3) Delete all Roles ----
elif choice == "3": elif choice == "3":
console.print("") console.print("")
community_name = self.validation_results.get("fluxer_community_name", "Unknown") 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 Fluxer community: \"{community_name}\"[/bold red]") 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]") 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]"): if not Confirm.ask("[bold red]Are you absolutely sure?[/bold red]"):
return return
@ -1372,8 +1377,8 @@ class MigrationCLI:
# ---- (4) Delete all Emojis & Stickers ---- # ---- (4) Delete all Emojis & Stickers ----
elif choice == "4": elif choice == "4":
console.print("") console.print("")
community_name = self.validation_results.get("fluxer_community_name", "Unknown") 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 Fluxer community: \"{community_name}\"[/bold red]") 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]"): if not Confirm.ask("[bold red]Are you absolutely sure?[/bold red]"):
return return
if not Confirm.ask("[bold red]Last chance \u2013 this cannot be undone. Continue?[/bold red]"): if not Confirm.ask("[bold red]Last chance \u2013 this cannot be undone. Continue?[/bold red]"):