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.
If the channel does not exist, it will dynamically create it.
"""
# 1. Initialize or Validate channel
channel_id = context.state.audit_log_channel
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 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
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
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
)
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
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
)
# 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
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
# 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)

View file

@ -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"]
)

View file

@ -17,34 +17,6 @@ class AppConfig(BaseModel):
anonymize_users: bool = Field(default=False)
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:
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"):

View file

@ -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")}

View file

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

View file

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

View file

@ -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}")

View file

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

View file

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