This commit is contained in:
rambros 2026-03-22 20:36:11 +05:30
parent 97e22a7c40
commit 185afb1ee0
9 changed files with 96 additions and 121 deletions

View file

@ -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. Logs an event by sending a summary to the `#reaper-logs` audit channel.
If the channel does not exist, it will dynamically create it. If the channel does not exist, it will dynamically create it.
""" """
# 1. Initialize or Validate channel async with context._audit_lock:
channel_id = context.state.audit_log_channel # 1. Initialize or Validate channel
channel_id = context.state.audit_log_channel
if channel_id: if channel_id:
try: try:
channels = await context.writer.get_channels() channels = await context.writer.get_channels()
channel_exists = any(str(ch.get("id")) == str(channel_id) for ch in channels) channel_exists = any(str(ch.get("id")) == str(channel_id) for ch in channels)
if not channel_exists: if not channel_exists:
context.state.audit_log_channel = None context.state.audit_log_channel = None
except Exception as e: except Exception as e:
logger.warning(f"Failed to verify audit channel existence: {e}") logger.warning(f"Failed to verify audit channel existence: {e}")
if not context.state.audit_log_channel: if not context.state.audit_log_channel:
try: try:
channels = await context.writer.get_channels() channels = await context.writer.get_channels()
channel_id = None channel_id = None
for ch in channels: for ch in channels:
name = str(ch.get("name", "")).lower() name = str(ch.get("name", "")).lower()
if name in ["reaper-logs", "reaper_logs"]: if name in ["reaper-logs", "reaper_logs"]:
channel_id = str(ch.get("id")) channel_id = str(ch.get("id"))
break break
if not channel_id: if not channel_id:
channel_id = await context.writer.create_channel( channel_id = await context.writer.create_channel(
name="reaper-logs", name="reaper-logs",
topic="Discord Reaper - Migration audit logs.", topic="Discord Reaper - Migration audit logs.",
type=0 type=0
) )
context.state.audit_log_channel = channel_id context.state.audit_log_channel = channel_id
# For Fluxer, we still do the lockdown as it's proven to work # For Fluxer, we still do the lockdown as it's proven to work
if context.target_platform == "fluxer": if context.target_platform == "fluxer":
await context.writer.set_channel_permission( await context.writer.set_channel_permission(
channel_id=channel_id, channel_id=channel_id,
overwrite_id=context.writer.community_id, overwrite_id=context.writer.community_id,
allow=0, allow=0,
deny=1024, deny=1024,
is_role=True is_role=True
) )
context.state.save_state() context.state.save_state()
except Exception as e: except Exception as e:
logger.error(f"Failed to setup audit log channel: {e}") logger.error(f"Failed to setup audit log channel: {e}")
return return
# 2. Format and send the message # 2. Format and send the message (outside the lock since setup is done)
content = f"**[{title}]**\n{description}" content = f"**[{title}]**\n{description}"
try: try:
await context.writer.send_marker(context.state.audit_log_channel, content, files=files) await context.writer.send_marker(context.state.audit_log_channel, content, files=files)

View file

@ -1,3 +1,4 @@
import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Dict, Any from typing import Dict, Any
@ -20,6 +21,7 @@ class MigrationContext:
# If caller didn't specify, fall back to config value # If caller didn't specify, fall back to config value
self.target_platform = target_platform or config.target_platform or "fluxer" self.target_platform = target_platform or config.target_platform or "fluxer"
self.state = MigrationState() self.state = MigrationState()
self._audit_lock = asyncio.Lock()
# Select the appropriate source reader # Select the appropriate source reader
if source_mode == "backup": if source_mode == "backup":
@ -35,15 +37,17 @@ class MigrationContext:
logger.info("Source mode: LIVE — using Discord API") logger.info("Source mode: LIVE — using Discord API")
# Build the writer for the active target platform only # 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": 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.writer = StoatWriter(token=token, community_id=community_id, api_url=api_url)
self.stoat_writer = self.writer self.stoat_writer = self.writer
self.fluxer_writer = FluxerWriter(token="", community_id="", api_url="default") self.fluxer_writer = FluxerWriter(token="", community_id="", api_url="default")
else: 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.writer = FluxerWriter(token=token, community_id=community_id, api_url=api_url)
self.fluxer_writer = self.writer self.fluxer_writer = self.writer
self.stoat_writer = StoatWriter(token="", community_id="", api_url="default") 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. # CONSISTENCY: Once target metadata is known, initialize the flat SQLite DB.
if results["target_community"] and results["target_community_name"]: 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( self.ensure_state_initialized(
str(self.config.target_server_id or ""), str(tid or ""),
results["target_community_name"] results["target_community_name"]
) )

View file

@ -17,34 +17,6 @@ class AppConfig(BaseModel):
anonymize_users: bool = Field(default=False) anonymize_users: bool = Field(default=False)
log_level: str = Field(default="INFO") log_level: str = Field(default="INFO")
# ── backwardcompat shims (readonly) ────────────────────────────────
# 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: def load_config(config_path: Union[str, Path] = "config.yaml", create_if_missing: bool = True) -> AppConfig:
path = Path(config_path) path = Path(config_path)
if not path.exists(): if not path.exists():
@ -62,24 +34,6 @@ def load_config(config_path: Union[str, Path] = "config.yaml", create_if_missing
if not data: if not data:
raise ValueError("Configuration file is empty or invalid YAML.") 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) return AppConfig(**data)
def save_config(config: AppConfig, config_path: Union[str, Path] = "config.yaml"): def save_config(config: AppConfig, config_path: Union[str, Path] = "config.yaml"):

View file

@ -14,8 +14,8 @@ async def sync_assets_state(context: MigrationContext):
discord_emojis = await context.discord_reader.get_emojis() discord_emojis = await context.discord_reader.get_emojis()
discord_stickers = await context.discord_reader.get_stickers() discord_stickers = await context.discord_reader.get_stickers()
fluxer_emojis = await context.fluxer_writer.client.get_guild_emojis(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_community_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 # 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_emoji_map = {e.get("name"): str(e.get("id")) for e in fluxer_emojis if e.get("name")}

View file

@ -12,7 +12,7 @@ async def sync_roles_state(context: MigrationContext):
""" """
logger.info("Synchronizing role mappings with Fluxer...") logger.info("Synchronizing role mappings with Fluxer...")
discord_roles = await context.discord_reader.get_roles() 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 # 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_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) discord_role_id = str(target.id)
# Handle @everyone role special case # Handle @everyone role special case
if discord_role_id == context.config.discord_server_id: 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: else:
fluxer_role_id = context.state.get_fluxer_role_id(discord_role_id) fluxer_role_id = context.state.get_fluxer_role_id(discord_role_id)

View file

@ -200,6 +200,7 @@ class FluxerWriter:
rate_limit_per_user=slowmode_delay, rate_limit_per_user=slowmode_delay,
position=position position=position
) )
self._channels_cache = None
return str(guild_channel["id"]) 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: 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:

View file

@ -266,6 +266,7 @@ class StoatWriter:
else: # Text Channel else: # Text Channel
ch = await server.create_text_channel(name=name, description=topic) ch = await server.create_text_channel(name=name, description=topic)
# We no longer parent here, clone_server.py will do it in bulk # We no longer parent here, clone_server.py will do it in bulk
self._server = None # Clear cache
return str(ch.id) return str(ch.id)
except Exception as e: except Exception as e:
logger.error(f"Failed to create Stoat channel {name}: {e}") logger.error(f"Failed to create Stoat channel {name}: {e}")

View file

@ -327,8 +327,10 @@ class ConfigScreen(Screen):
) )
yield Label("Bot Token:", classes="field_label") yield Label("Bot Token:", classes="field_label")
with Horizontal(classes="fetch_row"): 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( yield Input(
value=self.config.target_bot_token or "", value=t_token or "",
id="inp_target_token", id="inp_target_token",
password=True, password=True,
placeholder="Paste Target Bot Token", placeholder="Paste Target Bot Token",
@ -344,8 +346,9 @@ class ConfigScreen(Screen):
) )
yield Label("Target API URL:", classes="field_label") 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( 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", id="inp_target_api",
placeholder="Leave this Empty for official instance", placeholder="Leave this Empty for official instance",
tooltip="Enter the custom API url\nfor self hosted instances" 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 # Also auto-fetch target servers if mode is not backup_only
if self._get_selected_mode() != "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": 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( self.run_worker(self._do_fetch_target_servers(
token=self.config.target_bot_token, token=t_token,
api_url=self.config.target_api_url, api_url=t_api,
platform=platform, platform=platform,
initial=True initial=True
)) ))

View file

@ -320,7 +320,7 @@ class OperationPane(Container):
enabled = (v.get("discord_token") and v.get("discord_server") and not d_missing) enabled = (v.get("discord_token") and v.get("discord_server") and not d_missing)
for bid in ("#op_backup_msgs", "#op_backup_sync"): 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"): for btn in self.query("#op_backup_stats"):
btn.display = self.has_backup btn.display = self.has_backup
@ -389,7 +389,7 @@ class OperationPane(Container):
# Buttons # Buttons
for bid in ("#op_clone", "#op_sync", "#op_messages", "#op_danger"): 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 ──────────────────────────────────────────────────────── # ── validation ────────────────────────────────────────────────────────
@ -438,8 +438,13 @@ class OperationPane(Container):
# Check what we have # Check what we have
has_d_token = bool(self.config.discord_bot_token) has_d_token = bool(self.config.discord_bot_token)
has_d_server = bool(self.config.discord_server_id) 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 # Flag which operations are being validated
validating_discord = False validating_discord = False
@ -1105,7 +1110,8 @@ class OperationPane(Container):
tgt_server_name = tgt_server_info.get("community_name", "target community") tgt_server_name = tgt_server_info.get("community_name", "target community")
# ENSURE INITIALIZED for mapping lookup in analyze/migrate # 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: if src_server:
modal.write(f"[bold cyan]Source Server Profile:[/bold cyan]") modal.write(f"[bold cyan]Source Server Profile:[/bold cyan]")
@ -1533,7 +1539,7 @@ class OperationPane(Container):
try: try:
if "dz_del_roles" in selections: if "dz_del_roles" in selections:
if is_fluxer: 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) roles_raw = await writer.client.get_guild_roles(community_id)
role_names = [ role_names = [
r.get("name", "Unknown") for r in roles_raw r.get("name", "Unknown") for r in roles_raw
@ -1554,7 +1560,7 @@ class OperationPane(Container):
if "dz_del_assets" in selections: if "dz_del_assets" in selections:
asset_names = [] asset_names = []
if is_fluxer: 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) emojis = await writer.client.get_guild_emojis(community_id)
asset_names += [f"{e.get('name', '?')} (emoji)" for e in emojis] asset_names += [f"{e.get('name', '?')} (emoji)" for e in emojis]
try: try:
@ -1592,23 +1598,24 @@ class OperationPane(Container):
target_stickers_map = {} target_stickers_map = {}
try: try:
if is_fluxer: 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_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} 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 # RE-INITIALIZE STATE if we found community info
# This ensures mapping persistence even if validate_all was skipped # This ensures mapping persistence even if validate_all was skipped
try: 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: 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: except Exception:
pass pass
try: 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} target_stickers_map = {s.get("name", "").lower(): str(s.get("id")) for s in target_stickers_raw}
except Exception: except Exception:
pass pass
@ -1620,7 +1627,8 @@ class OperationPane(Container):
target_emojis_map = {e.name.lower(): str(e.id) for e in target_emojis_raw} target_emojis_map = {e.name.lower(): str(e.id) for e in target_emojis_raw}
# RE-INITIALIZE STATE # 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_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} target_chans_map = {c.get("name", "").lower(): str(c.get("id")) for c in target_chans_raw if c.get("type") != 4}