diff --git a/src/core/audit.py b/src/core/audit.py index f52e7be..7529f69 100644 --- a/src/core/audit.py +++ b/src/core/audit.py @@ -8,54 +8,55 @@ async def log_audit_event(context: MigrationContext, title: str, description: st Logs an event by sending a summary to the `#reaper-logs` audit channel. If the channel does not exist, it will dynamically create it. """ - # 1. Initialize or Validate channel - channel_id = context.state.audit_log_channel - - if channel_id: - try: - channels = await context.writer.get_channels() - channel_exists = any(str(ch.get("id")) == str(channel_id) for ch in channels) - if not channel_exists: - context.state.audit_log_channel = None - except Exception as e: - logger.warning(f"Failed to verify audit channel existence: {e}") + async with context._audit_lock: + # 1. Initialize or Validate channel + channel_id = context.state.audit_log_channel + + if channel_id: + try: + channels = await context.writer.get_channels() + channel_exists = any(str(ch.get("id")) == str(channel_id) for ch in channels) + if not channel_exists: + context.state.audit_log_channel = None + except Exception as e: + logger.warning(f"Failed to verify audit channel existence: {e}") - if not context.state.audit_log_channel: - try: - channels = await context.writer.get_channels() - channel_id = None - - for ch in channels: - name = str(ch.get("name", "")).lower() - if name in ["reaper-logs", "reaper_logs"]: - channel_id = str(ch.get("id")) - break - - if not channel_id: - channel_id = await context.writer.create_channel( - name="reaper-logs", - topic="Discord Reaper - Migration audit logs.", - type=0 - ) - - context.state.audit_log_channel = channel_id - - # For Fluxer, we still do the lockdown as it's proven to work - if context.target_platform == "fluxer": - await context.writer.set_channel_permission( - channel_id=channel_id, - overwrite_id=context.writer.community_id, - allow=0, - deny=1024, - is_role=True - ) - - context.state.save_state() - except Exception as e: - logger.error(f"Failed to setup audit log channel: {e}") - return - - # 2. Format and send the message + if not context.state.audit_log_channel: + try: + channels = await context.writer.get_channels() + channel_id = None + + for ch in channels: + name = str(ch.get("name", "")).lower() + if name in ["reaper-logs", "reaper_logs"]: + channel_id = str(ch.get("id")) + break + + if not channel_id: + channel_id = await context.writer.create_channel( + name="reaper-logs", + topic="Discord Reaper - Migration audit logs.", + type=0 + ) + + context.state.audit_log_channel = channel_id + + # For Fluxer, we still do the lockdown as it's proven to work + if context.target_platform == "fluxer": + await context.writer.set_channel_permission( + channel_id=channel_id, + overwrite_id=context.writer.community_id, + allow=0, + deny=1024, + is_role=True + ) + + context.state.save_state() + except Exception as e: + logger.error(f"Failed to setup audit log channel: {e}") + return + + # 2. Format and send the message (outside the lock since setup is done) content = f"**[{title}]**\n{description}" try: await context.writer.send_marker(context.state.audit_log_channel, content, files=files) diff --git a/src/core/base.py b/src/core/base.py index 646c6cf..44640ec 100644 --- a/src/core/base.py +++ b/src/core/base.py @@ -1,3 +1,4 @@ +import asyncio import logging from pathlib import Path from typing import Dict, Any @@ -20,6 +21,7 @@ class MigrationContext: # If caller didn't specify, fall back to config value self.target_platform = target_platform or config.target_platform or "fluxer" self.state = MigrationState() + self._audit_lock = asyncio.Lock() # Select the appropriate source reader if source_mode == "backup": @@ -35,15 +37,17 @@ class MigrationContext: logger.info("Source mode: LIVE — using Discord API") # Build the writer for the active target platform only - token = config.target_bot_token or "" - community_id = config.target_server_id or "" - api_url = config.target_api_url or "default" - if self.target_platform == "stoat": + token = config.stoat_bot_token or "" + community_id = config.stoat_server_id or "" + api_url = config.stoat_api_url or "default" self.writer = StoatWriter(token=token, community_id=community_id, api_url=api_url) self.stoat_writer = self.writer self.fluxer_writer = FluxerWriter(token="", community_id="", api_url="default") else: + token = config.fluxer_bot_token or "" + community_id = config.fluxer_server_id or "" + api_url = config.fluxer_api_url or "default" self.writer = FluxerWriter(token=token, community_id=community_id, api_url=api_url) self.fluxer_writer = self.writer self.stoat_writer = StoatWriter(token="", community_id="", api_url="default") @@ -104,8 +108,9 @@ class MigrationContext: # CONSISTENCY: Once target metadata is known, initialize the flat SQLite DB. if results["target_community"] and results["target_community_name"]: + tid = self.config.fluxer_server_id if self.target_platform == "fluxer" else self.config.stoat_server_id self.ensure_state_initialized( - str(self.config.target_server_id or ""), + str(tid or ""), results["target_community_name"] ) diff --git a/src/core/configuration.py b/src/core/configuration.py index 1f89d4f..db52fd5 100644 --- a/src/core/configuration.py +++ b/src/core/configuration.py @@ -17,34 +17,6 @@ class AppConfig(BaseModel): anonymize_users: bool = Field(default=False) log_level: str = Field(default="INFO") - # ── backward‑compat shims (read‑only) ──────────────────────────────── - # The rest of the codebase (fluxer/stoat modules) still reads these. - # They all delegate to the unified target_* fields. - - @property - def use_fluxer(self) -> bool: - return self.target_platform == "fluxer" - - @property - def use_stoat(self) -> bool: - return self.target_platform == "stoat" - - @property - def target_bot_token(self) -> Optional[str]: - return self.fluxer_bot_token if self.target_platform == "fluxer" else self.stoat_bot_token - - @property - def target_server_id(self) -> Optional[str]: - return self.fluxer_server_id if self.target_platform == "fluxer" else self.stoat_server_id - - @property - def target_api_url(self) -> Optional[str]: - return self.fluxer_api_url if self.target_platform == "fluxer" else self.stoat_api_url - - @property - def fluxer_community_id(self) -> Optional[str]: - return self.fluxer_server_id - def load_config(config_path: Union[str, Path] = "config.yaml", create_if_missing: bool = True) -> AppConfig: path = Path(config_path) if not path.exists(): @@ -62,24 +34,6 @@ def load_config(config_path: Union[str, Path] = "config.yaml", create_if_missing if not data: raise ValueError("Configuration file is empty or invalid YAML.") - # ── migrate legacy configs that used single target fields ── - if "fluxer_community_id" in data: - data.setdefault("fluxer_server_id", data.pop("fluxer_community_id")) - - if "target_bot_token" in data or "target_server_id" in data: - platform = data.get("target_platform", "fluxer") - if platform == "fluxer": - data.setdefault("fluxer_bot_token", data.get("target_bot_token")) - data.setdefault("fluxer_server_id", data.get("target_server_id")) - data.setdefault("fluxer_api_url", data.get("target_api_url")) - elif platform == "stoat": - data.setdefault("stoat_bot_token", data.get("target_bot_token")) - data.setdefault("stoat_server_id", data.get("target_server_id")) - data.setdefault("stoat_api_url", data.get("target_api_url")) - - for key in ("target_bot_token", "target_server_id", "target_api_url", "use_fluxer", "use_stoat"): - data.pop(key, None) - return AppConfig(**data) def save_config(config: AppConfig, config_path: Union[str, Path] = "config.yaml"): diff --git a/src/fluxer/emoji_stickers.py b/src/fluxer/emoji_stickers.py index 8e339d2..f8de0ec 100644 --- a/src/fluxer/emoji_stickers.py +++ b/src/fluxer/emoji_stickers.py @@ -14,8 +14,8 @@ async def sync_assets_state(context: MigrationContext): 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) + fluxer_emojis = await context.fluxer_writer.client.get_guild_emojis(context.config.fluxer_server_id) + fluxer_stickers = await context.fluxer_writer.client.get_guild_stickers(context.config.fluxer_server_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")} diff --git a/src/fluxer/roles_permissions.py b/src/fluxer/roles_permissions.py index 407c7b8..83ff25b 100644 --- a/src/fluxer/roles_permissions.py +++ b/src/fluxer/roles_permissions.py @@ -12,7 +12,7 @@ async def sync_roles_state(context: MigrationContext): """ logger.info("Synchronizing role mappings with Fluxer...") discord_roles = await context.discord_reader.get_roles() - fluxer_roles = await context.fluxer_writer.client.get_guild_roles(context.config.fluxer_community_id) + fluxer_roles = await context.fluxer_writer.client.get_guild_roles(context.config.fluxer_server_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")} @@ -70,7 +70,7 @@ async def sync_permissions(context: MigrationContext, progress_callback: Callabl 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 + fluxer_role_id = context.config.fluxer_server_id else: fluxer_role_id = context.state.get_fluxer_role_id(discord_role_id) diff --git a/src/fluxer/writer.py b/src/fluxer/writer.py index da719fd..beb74de 100644 --- a/src/fluxer/writer.py +++ b/src/fluxer/writer.py @@ -200,6 +200,7 @@ class FluxerWriter: rate_limit_per_user=slowmode_delay, position=position ) + self._channels_cache = None return str(guild_channel["id"]) async def modify_channel(self, channel_id: str, parent_id: Optional[str] = None, name: Optional[str] = None, topic: Optional[str] = None, nsfw: Optional[bool] = None, slowmode_delay: Optional[int] = None, position: Optional[int] = None) -> bool: diff --git a/src/stoat/writer.py b/src/stoat/writer.py index d44b6cd..3d3f49d 100644 --- a/src/stoat/writer.py +++ b/src/stoat/writer.py @@ -266,6 +266,7 @@ class StoatWriter: else: # Text Channel ch = await server.create_text_channel(name=name, description=topic) # We no longer parent here, clone_server.py will do it in bulk + self._server = None # Clear cache return str(ch.id) except Exception as e: logger.error(f"Failed to create Stoat channel {name}: {e}") diff --git a/src/ui/main_app.py b/src/ui/main_app.py index d13e0a6..3708d8c 100644 --- a/src/ui/main_app.py +++ b/src/ui/main_app.py @@ -327,8 +327,10 @@ class ConfigScreen(Screen): ) yield Label("Bot Token:", classes="field_label") with Horizontal(classes="fetch_row"): + cur_plat = self.config.target_platform or "fluxer" + t_token = self.config.stoat_bot_token if cur_plat == "stoat" else self.config.fluxer_bot_token yield Input( - value=self.config.target_bot_token or "", + value=t_token or "", id="inp_target_token", password=True, placeholder="Paste Target Bot Token", @@ -344,8 +346,9 @@ class ConfigScreen(Screen): ) yield Label("Target API URL:", classes="field_label") + t_api = self.config.stoat_api_url if cur_plat == "stoat" else self.config.fluxer_api_url yield Input( - value=self.config.target_api_url if (self.config.target_api_url and self.config.target_api_url != "default") else "", + value=t_api if (t_api and t_api != "default") else "", id="inp_target_api", placeholder="Leave this Empty for official instance", tooltip="Enter the custom API url\nfor self hosted instances" @@ -373,12 +376,14 @@ class ConfigScreen(Screen): # Also auto-fetch target servers if mode is not backup_only if self._get_selected_mode() != "backup_only": - if self.config.target_bot_token: - platform = self.config.target_platform + platform = self.config.target_platform + t_token = self.config.stoat_bot_token if platform == "stoat" else self.config.fluxer_bot_token + if t_token: if platform != "none": + t_api = self.config.stoat_api_url if platform == "stoat" else self.config.fluxer_api_url self.run_worker(self._do_fetch_target_servers( - token=self.config.target_bot_token, - api_url=self.config.target_api_url, + token=t_token, + api_url=t_api, platform=platform, initial=True )) diff --git a/src/ui/shuttle_ops.py b/src/ui/shuttle_ops.py index d8396fc..0a1b3e2 100644 --- a/src/ui/shuttle_ops.py +++ b/src/ui/shuttle_ops.py @@ -320,7 +320,7 @@ class OperationPane(Container): enabled = (v.get("discord_token") and v.get("discord_server") and not d_missing) for bid in ("#op_backup_msgs", "#op_backup_sync"): - self.query_one(bid, Button).disabled = not enabled + for btn in self.query(bid): btn.disabled = not enabled for btn in self.query("#op_backup_stats"): btn.display = self.has_backup @@ -389,7 +389,7 @@ class OperationPane(Container): # Buttons for bid in ("#op_clone", "#op_sync", "#op_messages", "#op_danger"): - self.query_one(bid, Button).disabled = not self.tokens_valid + for btn in self.query(bid): btn.disabled = not self.tokens_valid # ── validation ──────────────────────────────────────────────────────── @@ -438,8 +438,13 @@ class OperationPane(Container): # Check what we have has_d_token = bool(self.config.discord_bot_token) has_d_server = bool(self.config.discord_server_id) - has_t_token = bool(self.config.target_bot_token) - has_t_server = bool(self.config.target_server_id) + + if self.target_platform == "stoat": + has_t_token = bool(self.config.stoat_bot_token) + has_t_server = bool(self.config.stoat_server_id) + else: + has_t_token = bool(self.config.fluxer_bot_token) + has_t_server = bool(self.config.fluxer_server_id) # Flag which operations are being validated validating_discord = False @@ -1105,7 +1110,8 @@ class OperationPane(Container): tgt_server_name = tgt_server_info.get("community_name", "target community") # ENSURE INITIALIZED for mapping lookup in analyze/migrate - self.engine.ensure_state_initialized(str(self.engine.config.target_server_id), tgt_server_name) + tid = self.engine.config.fluxer_server_id if self.target_platform == "fluxer" else self.engine.config.stoat_server_id + self.engine.ensure_state_initialized(str(tid or ""), tgt_server_name) if src_server: modal.write(f"[bold cyan]Source Server Profile:[/bold cyan]") @@ -1533,7 +1539,7 @@ class OperationPane(Container): try: if "dz_del_roles" in selections: if is_fluxer: - community_id = self.engine.config.target_server_id + community_id = self.engine.config.fluxer_server_id roles_raw = await writer.client.get_guild_roles(community_id) role_names = [ r.get("name", "Unknown") for r in roles_raw @@ -1554,7 +1560,7 @@ class OperationPane(Container): if "dz_del_assets" in selections: asset_names = [] if is_fluxer: - community_id = self.engine.config.target_server_id + community_id = self.engine.config.fluxer_server_id emojis = await writer.client.get_guild_emojis(community_id) asset_names += [f"{e.get('name', '?')} (emoji)" for e in emojis] try: @@ -1592,23 +1598,24 @@ class OperationPane(Container): target_stickers_map = {} try: if is_fluxer: - target_roles_raw = await writer.client.get_guild_roles(self.engine.config.target_server_id) + tid = self.engine.config.fluxer_server_id + target_roles_raw = await writer.client.get_guild_roles(tid) target_roles_map = {r.get("name", "").lower(): str(r.get("id")) for r in target_roles_raw} - target_emojis_raw = await writer.client.get_guild_emojis(self.engine.config.target_server_id) + target_emojis_raw = await writer.client.get_guild_emojis(tid) target_emojis_map = {e.get("name", "").lower(): str(e.get("id")) for e in target_emojis_raw} # RE-INITIALIZE STATE if we found community info # This ensures mapping persistence even if validate_all was skipped try: - community_info = await writer.client.get_guild(self.engine.config.target_server_id) + community_info = await writer.client.get_guild(tid) if community_info: - self.engine.ensure_state_initialized(str(self.engine.config.target_server_id), community_info.get("name", "Target")) + self.engine.ensure_state_initialized(str(tid or ""), community_info.get("name", "Target")) except Exception: pass try: - target_stickers_raw = await writer.client.get_guild_stickers(self.engine.config.target_server_id) + target_stickers_raw = await writer.client.get_guild_stickers(tid) target_stickers_map = {s.get("name", "").lower(): str(s.get("id")) for s in target_stickers_raw} except Exception: pass @@ -1620,7 +1627,8 @@ class OperationPane(Container): target_emojis_map = {e.name.lower(): str(e.id) for e in target_emojis_raw} # RE-INITIALIZE STATE - self.engine.ensure_state_initialized(str(self.engine.config.target_server_id), server.name) + tid = self.engine.config.stoat_server_id + self.engine.ensure_state_initialized(str(tid or ""), server.name) target_chans_raw = await writer.get_channels() target_chans_map = {c.get("name", "").lower(): str(c.get("id")) for c in target_chans_raw if c.get("type") != 4}