functions refactor
This commit is contained in:
parent
e5d844d77d
commit
6600be74c3
15 changed files with 719 additions and 682 deletions
|
|
@ -2,7 +2,7 @@ import sys
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from src.ui.app import run_cli
|
from src.ui.app import run_cli
|
||||||
from src.config import load_config
|
from src.core.configuration import load_config
|
||||||
|
|
||||||
def setup_logging():
|
def setup_logging():
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
74
src/core/base.py
Normal file
74
src/core/base.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from src.core.configuration import AppConfig
|
||||||
|
from src.core.state import MigrationState
|
||||||
|
from src.core.discord_reader import DiscordReader
|
||||||
|
from src.core.fluxer_writer import FluxerWriter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class MigrationContext:
|
||||||
|
"""Holds state and connections for reading from Discord and writing to Fluxer."""
|
||||||
|
|
||||||
|
def __init__(self, config: AppConfig):
|
||||||
|
self.config = config
|
||||||
|
self.state = MigrationState()
|
||||||
|
|
||||||
|
self.discord_reader = DiscordReader(
|
||||||
|
token=config.discord_bot_token,
|
||||||
|
server_id=config.discord_server_id
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fluxer_writer = FluxerWriter(
|
||||||
|
token=config.fluxer_bot_token,
|
||||||
|
community_id=config.fluxer_community_id
|
||||||
|
)
|
||||||
|
|
||||||
|
self.is_running = False
|
||||||
|
|
||||||
|
async def validate_all(self) -> Dict[str, Any]:
|
||||||
|
"""Returns connection validation status as a dictionary."""
|
||||||
|
try:
|
||||||
|
d_valid = await self.discord_reader.validate()
|
||||||
|
f_valid = await self.fluxer_writer.validate()
|
||||||
|
return {
|
||||||
|
"discord_token": d_valid.get("token", False),
|
||||||
|
"discord_bot_name": d_valid.get("bot_name"),
|
||||||
|
"discord_server": d_valid.get("server", False),
|
||||||
|
"discord_server_name": d_valid.get("server_name"),
|
||||||
|
"discord_intents": d_valid.get("intents", {}),
|
||||||
|
"discord_permissions": d_valid.get("permissions", {}),
|
||||||
|
"fluxer_token": f_valid.get("token", False),
|
||||||
|
"fluxer_bot_name": f_valid.get("bot_name"),
|
||||||
|
"fluxer_community": f_valid.get("community", False),
|
||||||
|
"fluxer_community_name": f_valid.get("community_name"),
|
||||||
|
"fluxer_permissions": f_valid.get("permissions", {})
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Validation failed with exception: {e}")
|
||||||
|
return {
|
||||||
|
"discord_token": False,
|
||||||
|
"discord_server": False,
|
||||||
|
"fluxer_token": False,
|
||||||
|
"fluxer_community": False
|
||||||
|
}
|
||||||
|
|
||||||
|
async def start_connections(self):
|
||||||
|
await self.discord_reader.start()
|
||||||
|
await self.fluxer_writer.start()
|
||||||
|
|
||||||
|
async def start_fluxer_only(self):
|
||||||
|
"""Starts only the Fluxer writer (used for Danger Zone operations that don't need Discord)."""
|
||||||
|
await self.fluxer_writer.start()
|
||||||
|
|
||||||
|
async def close_connections(self):
|
||||||
|
await self.discord_reader.close()
|
||||||
|
await self.fluxer_writer.close()
|
||||||
|
|
||||||
|
async def close_fluxer_only(self):
|
||||||
|
"""Closes only the Fluxer writer. Pair with start_fluxer_only()."""
|
||||||
|
await self.fluxer_writer.close()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.is_running = False
|
||||||
166
src/core/clone_server.py
Normal file
166
src/core/clone_server.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Callable, Awaitable
|
||||||
|
|
||||||
|
from src.core.base import MigrationContext
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def sync_channel_state(context: MigrationContext):
|
||||||
|
"""
|
||||||
|
Scans Fluxer for channels matching Discord names and updates state.json mappings.
|
||||||
|
This prevents duplicate creation when the state.json is empty but channels exist in Fluxer.
|
||||||
|
"""
|
||||||
|
categories = await context.discord_reader.get_categories()
|
||||||
|
channels = await context.discord_reader.get_channels()
|
||||||
|
fluxer_channels = await context.fluxer_writer.get_channels()
|
||||||
|
|
||||||
|
# Build name -> id map and ID set for Fluxer for fast lookup
|
||||||
|
fluxer_name_map = {c.get("name"): str(c.get("id")) for c in fluxer_channels if c.get("name")}
|
||||||
|
fluxer_id_set = {str(c.get("id")) for c in fluxer_channels}
|
||||||
|
|
||||||
|
updates = 0
|
||||||
|
removals = 0
|
||||||
|
|
||||||
|
# 1. Verify and Sync Categories
|
||||||
|
for cat in categories:
|
||||||
|
discord_id = str(cat.id)
|
||||||
|
fluxer_id = context.state.get_fluxer_category_id(discord_id)
|
||||||
|
|
||||||
|
if fluxer_id:
|
||||||
|
if fluxer_id not in fluxer_id_set:
|
||||||
|
context.state.remove_category_mapping(discord_id)
|
||||||
|
removals += 1
|
||||||
|
elif cat.name in fluxer_name_map:
|
||||||
|
context.state.set_category_mapping(discord_id, fluxer_name_map[cat.name])
|
||||||
|
updates += 1
|
||||||
|
|
||||||
|
# 2. Verify and Sync Channels
|
||||||
|
for ch in channels:
|
||||||
|
discord_id = str(ch.id)
|
||||||
|
fluxer_id = context.state.get_fluxer_channel_id(discord_id)
|
||||||
|
|
||||||
|
if fluxer_id:
|
||||||
|
if fluxer_id not in fluxer_id_set:
|
||||||
|
context.state.remove_channel_mapping(discord_id)
|
||||||
|
removals += 1
|
||||||
|
elif ch.name in fluxer_name_map:
|
||||||
|
context.state.set_channel_mapping(discord_id, fluxer_name_map[ch.name])
|
||||||
|
updates += 1
|
||||||
|
|
||||||
|
if updates > 0 or removals > 0:
|
||||||
|
logger.info(f"Channel sync: {updates} mapped, {removals} stale mappings removed")
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_channels(context: MigrationContext, progress_callback: Callable[[str, str, int, int], Awaitable[None]] | None = None, force: bool = False):
|
||||||
|
"""Clones categories and text channels.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
progress_callback: Optional callback receiving (item_name, status, current, total)
|
||||||
|
force: If True, re-create channels even if they exist in state.
|
||||||
|
"""
|
||||||
|
categories = await context.discord_reader.get_categories()
|
||||||
|
channels = await context.discord_reader.get_channels()
|
||||||
|
|
||||||
|
# 1. Identify categories to create
|
||||||
|
missing_categories = [cat for cat in categories if force or not context.state.get_fluxer_category_id(str(cat.id))]
|
||||||
|
missing_category_ids = {str(cat.id) for cat in missing_categories}
|
||||||
|
|
||||||
|
# 2. Identify channels to create or move
|
||||||
|
# Fetch current Fluxer state to check parent_ids
|
||||||
|
fluxer_channels = await context.fluxer_writer.get_channels()
|
||||||
|
fluxer_parent_map = {str(c["id"]): (str(c.get("parent_id")) if c.get("parent_id") else None) for c in fluxer_channels}
|
||||||
|
|
||||||
|
channels_to_create = []
|
||||||
|
channels_to_move = []
|
||||||
|
|
||||||
|
for ch in channels:
|
||||||
|
discord_id = str(ch.id)
|
||||||
|
fluxer_id = context.state.get_fluxer_channel_id(discord_id)
|
||||||
|
|
||||||
|
if force or not fluxer_id:
|
||||||
|
# We'll resolve the parent_id in the loop after categories are created
|
||||||
|
channels_to_create.append(ch)
|
||||||
|
else:
|
||||||
|
# Always add to move/sync list to ensure properties (topic, nsfw, slowmode) are synced
|
||||||
|
# even if the parent category is already correct.
|
||||||
|
channels_to_move.append((ch, fluxer_id))
|
||||||
|
|
||||||
|
total = len(missing_categories) + len(channels_to_create) + len(channels_to_move)
|
||||||
|
current_idx = 0
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Migrate Categories first
|
||||||
|
for cat in missing_categories:
|
||||||
|
if not context.is_running: break
|
||||||
|
|
||||||
|
state_key = str(cat.id)
|
||||||
|
fluxer_id = await context.fluxer_writer.create_channel(cat.name, type=4)
|
||||||
|
context.state.set_category_mapping(state_key, fluxer_id)
|
||||||
|
|
||||||
|
current_idx += 1
|
||||||
|
if progress_callback: await progress_callback(f"Cat: {cat.name}", "Copying", current_idx, total)
|
||||||
|
await asyncio.sleep(context.config.migration.rate_limit_delay_seconds)
|
||||||
|
|
||||||
|
# Create missing channels
|
||||||
|
for channel in channels_to_create:
|
||||||
|
if not context.is_running: break
|
||||||
|
|
||||||
|
state_key = str(channel.id)
|
||||||
|
topic = getattr(channel, 'topic', "") or ""
|
||||||
|
nsfw = getattr(channel, 'nsfw', False)
|
||||||
|
slowmode = getattr(channel, 'slowmode_delay', 0)
|
||||||
|
|
||||||
|
logger.debug(f"Creating channel {channel.name}: topic={topic}, nsfw={nsfw}, slowmode={slowmode}")
|
||||||
|
|
||||||
|
parent_id = context.state.get_fluxer_category_id(str(channel.category_id)) if channel.category_id else None
|
||||||
|
|
||||||
|
fluxer_id = await context.fluxer_writer.create_channel(
|
||||||
|
name=channel.name,
|
||||||
|
topic=topic,
|
||||||
|
type=0,
|
||||||
|
parent_id=parent_id,
|
||||||
|
nsfw=nsfw,
|
||||||
|
slowmode_delay=slowmode
|
||||||
|
)
|
||||||
|
context.state.set_channel_mapping(state_key, fluxer_id)
|
||||||
|
|
||||||
|
# Sync again immediately because some properties (like slowmode) are ignored on creation
|
||||||
|
await context.fluxer_writer.modify_channel(
|
||||||
|
channel_id=fluxer_id,
|
||||||
|
parent_id=parent_id,
|
||||||
|
name=channel.name,
|
||||||
|
topic=topic,
|
||||||
|
nsfw=nsfw,
|
||||||
|
slowmode_delay=slowmode
|
||||||
|
)
|
||||||
|
|
||||||
|
current_idx += 1
|
||||||
|
if progress_callback: await progress_callback(channel.name, "Copying", current_idx, total)
|
||||||
|
await asyncio.sleep(context.config.migration.rate_limit_delay_seconds)
|
||||||
|
|
||||||
|
# Move/Sync existing channels
|
||||||
|
for channel, fluxer_id in channels_to_move:
|
||||||
|
if not context.is_running: break
|
||||||
|
|
||||||
|
parent_id = context.state.get_fluxer_category_id(str(channel.category_id)) if channel.category_id else None
|
||||||
|
nsfw = getattr(channel, 'nsfw', False)
|
||||||
|
slowmode = getattr(channel, 'slowmode_delay', 0)
|
||||||
|
topic = getattr(channel, 'topic', "") or ""
|
||||||
|
|
||||||
|
logger.debug(f"Syncing existing channel {channel.name} ({fluxer_id}): topic={topic}, nsfw={nsfw}, slowmode={slowmode}")
|
||||||
|
|
||||||
|
await context.fluxer_writer.modify_channel(
|
||||||
|
channel_id=fluxer_id,
|
||||||
|
parent_id=parent_id,
|
||||||
|
name=channel.name,
|
||||||
|
topic=topic,
|
||||||
|
nsfw=nsfw,
|
||||||
|
slowmode_delay=slowmode
|
||||||
|
)
|
||||||
|
|
||||||
|
current_idx += 1
|
||||||
|
if progress_callback: await progress_callback(channel.name, "Syncing", current_idx, total)
|
||||||
|
await asyncio.sleep(context.config.migration.rate_limit_delay_seconds)
|
||||||
33
src/core/danger_zone.py
Normal file
33
src/core/danger_zone.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import logging
|
||||||
|
from typing import Callable, Awaitable
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
context.state.clear_asset_mappings()
|
||||||
|
return counts
|
||||||
106
src/core/emoji_stickers.py
Normal file
106
src/core/emoji_stickers.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
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 Fluxer for emojis and stickers matching Discord names and updates state.json mappings.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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_sticker_map = {s.get("name"): str(s.get("id")) for s in fluxer_stickers if s.get("name")}
|
||||||
|
fluxer_emoji_ids = {str(e.get("id")) for e in fluxer_emojis}
|
||||||
|
fluxer_sticker_ids = {str(s.get("id")) for s in fluxer_stickers}
|
||||||
|
|
||||||
|
updates = 0
|
||||||
|
removals = 0
|
||||||
|
|
||||||
|
# 1. Verify and Sync Emojis
|
||||||
|
for emoji in discord_emojis:
|
||||||
|
discord_id = str(emoji.id)
|
||||||
|
fluxer_id = context.state.get_fluxer_emoji_id(discord_id)
|
||||||
|
|
||||||
|
if fluxer_id:
|
||||||
|
if fluxer_id not in fluxer_emoji_ids:
|
||||||
|
context.state.remove_emoji_mapping(discord_id)
|
||||||
|
removals += 1
|
||||||
|
elif emoji.name in fluxer_emoji_map:
|
||||||
|
context.state.set_emoji_mapping(discord_id, fluxer_emoji_map[emoji.name])
|
||||||
|
updates += 1
|
||||||
|
|
||||||
|
# 2. Verify and Sync Stickers
|
||||||
|
for sticker in discord_stickers:
|
||||||
|
discord_id = str(sticker.id)
|
||||||
|
fluxer_id = context.state.get_fluxer_sticker_id(discord_id)
|
||||||
|
|
||||||
|
if fluxer_id:
|
||||||
|
if fluxer_id not in fluxer_sticker_ids:
|
||||||
|
context.state.remove_sticker_mapping(discord_id)
|
||||||
|
removals += 1
|
||||||
|
elif sticker.name in fluxer_sticker_map:
|
||||||
|
context.state.set_sticker_mapping(discord_id, fluxer_sticker_map[sticker.name])
|
||||||
|
updates += 1
|
||||||
|
|
||||||
|
if updates > 0 or removals > 0:
|
||||||
|
logger.info(f"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):
|
||||||
|
"""Copies custom emojis and stickers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force: If True, skip state cache and re-copy even if already migrated.
|
||||||
|
"""
|
||||||
|
objs = []
|
||||||
|
if "Emoji" in types_to_include:
|
||||||
|
emojis = await context.discord_reader.get_emojis()
|
||||||
|
objs.extend([(e, "Emoji") for e in emojis])
|
||||||
|
if "Sticker" in types_to_include:
|
||||||
|
stickers = await context.discord_reader.get_stickers()
|
||||||
|
objs.extend([(s, "Sticker") for s in stickers])
|
||||||
|
|
||||||
|
if not force:
|
||||||
|
objs = [(obj, obj_type) for obj, obj_type in objs if not (
|
||||||
|
context.state.get_fluxer_emoji_id(str(obj.id)) if obj_type == "Emoji" else context.state.get_fluxer_sticker_id(str(obj.id))
|
||||||
|
)]
|
||||||
|
|
||||||
|
total = len(objs)
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
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)
|
||||||
|
fluxer_id = await context.fluxer_writer.create_emoji(
|
||||||
|
name=obj.name,
|
||||||
|
image_bytes=img_data
|
||||||
|
)
|
||||||
|
if fluxer_id:
|
||||||
|
context.state.set_emoji_mapping(str(obj.id), fluxer_id)
|
||||||
|
else:
|
||||||
|
img_data = await context.discord_reader.download_sticker(obj)
|
||||||
|
fluxer_id = await context.fluxer_writer.create_sticker(
|
||||||
|
name=obj.name,
|
||||||
|
image_bytes=img_data
|
||||||
|
)
|
||||||
|
if fluxer_id:
|
||||||
|
context.state.set_sticker_mapping(str(obj.id), fluxer_id)
|
||||||
|
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)
|
||||||
|
|
@ -1,662 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from typing import Callable, Awaitable, List, Dict, Any
|
|
||||||
from src.config import AppConfig
|
|
||||||
from src.core.state import MigrationState
|
|
||||||
from src.discord_bot.reader import DiscordReader
|
|
||||||
from src.fluxer_bot.writer import FluxerWriter
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class MigrationEngine:
|
|
||||||
"""Orchestrates reading from Discord and writing to Fluxer."""
|
|
||||||
|
|
||||||
def __init__(self, config: AppConfig):
|
|
||||||
self.config = config
|
|
||||||
self.state = MigrationState()
|
|
||||||
|
|
||||||
self.discord_reader = DiscordReader(
|
|
||||||
token=config.discord_bot_token,
|
|
||||||
server_id=config.discord_server_id
|
|
||||||
)
|
|
||||||
|
|
||||||
self.fluxer_writer = FluxerWriter(
|
|
||||||
token=config.fluxer_bot_token,
|
|
||||||
community_id=config.fluxer_community_id
|
|
||||||
)
|
|
||||||
|
|
||||||
self.is_running = False
|
|
||||||
|
|
||||||
async def validate_all(self) -> Dict[str, Any]:
|
|
||||||
"""Returns True if both connections are valid."""
|
|
||||||
try:
|
|
||||||
d_valid = await self.discord_reader.validate()
|
|
||||||
f_valid = await self.fluxer_writer.validate()
|
|
||||||
return {
|
|
||||||
"discord_token": d_valid.get("token", False),
|
|
||||||
"discord_bot_name": d_valid.get("bot_name"),
|
|
||||||
"discord_server": d_valid.get("server", False),
|
|
||||||
"discord_server_name": d_valid.get("server_name"),
|
|
||||||
"discord_intents": d_valid.get("intents", {}),
|
|
||||||
"discord_permissions": d_valid.get("permissions", {}),
|
|
||||||
"fluxer_token": f_valid.get("token", False),
|
|
||||||
"fluxer_bot_name": f_valid.get("bot_name"),
|
|
||||||
"fluxer_community": f_valid.get("community", False),
|
|
||||||
"fluxer_community_name": f_valid.get("community_name"),
|
|
||||||
"fluxer_permissions": f_valid.get("permissions", {})
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Validation failed with exception: {e}")
|
|
||||||
return {
|
|
||||||
"discord_token": False,
|
|
||||||
"discord_server": False,
|
|
||||||
"fluxer_token": False,
|
|
||||||
"fluxer_community": False
|
|
||||||
}
|
|
||||||
|
|
||||||
async def start_connections(self):
|
|
||||||
await self.discord_reader.start()
|
|
||||||
await self.fluxer_writer.start()
|
|
||||||
|
|
||||||
async def start_fluxer_only(self):
|
|
||||||
"""Starts only the Fluxer writer (used for Danger Zone operations that don't need Discord)."""
|
|
||||||
await self.fluxer_writer.start()
|
|
||||||
|
|
||||||
async def close_connections(self):
|
|
||||||
await self.discord_reader.close()
|
|
||||||
await self.fluxer_writer.close()
|
|
||||||
|
|
||||||
async def close_fluxer_only(self):
|
|
||||||
"""Closes only the Fluxer writer. Pair with start_fluxer_only()."""
|
|
||||||
await self.fluxer_writer.close()
|
|
||||||
|
|
||||||
async def sync_server_metadata(self, progress_callback: Callable[[str, str], Awaitable[None]], components: List[str] = ["name", "icon", "banner"]):
|
|
||||||
"""Syncs the server name, logo and banner."""
|
|
||||||
metadata = await self.discord_reader.get_server_metadata()
|
|
||||||
|
|
||||||
# 1. Sync Name
|
|
||||||
if "name" in components:
|
|
||||||
try:
|
|
||||||
name = metadata.get("name")
|
|
||||||
await self.fluxer_writer.update_guild_metadata(name=name)
|
|
||||||
await progress_callback("Server Name", "DONE")
|
|
||||||
except Exception:
|
|
||||||
await progress_callback("Server Name", "ERROR")
|
|
||||||
|
|
||||||
# 2. Sync Icon
|
|
||||||
if "icon" in components:
|
|
||||||
try:
|
|
||||||
icon_bytes = None
|
|
||||||
if self.discord_reader.guild and self.discord_reader.guild.icon:
|
|
||||||
icon_bytes = await self.discord_reader.download_asset(self.discord_reader.guild.icon)
|
|
||||||
|
|
||||||
if icon_bytes:
|
|
||||||
await self.fluxer_writer.update_guild_metadata(icon=icon_bytes)
|
|
||||||
await progress_callback("Server Icon", "DONE")
|
|
||||||
else:
|
|
||||||
await progress_callback("Server Icon", "SKIP")
|
|
||||||
except Exception:
|
|
||||||
await progress_callback("Server Icon", "ERROR")
|
|
||||||
|
|
||||||
# 3. Sync Banner
|
|
||||||
if "banner" in components:
|
|
||||||
try:
|
|
||||||
banner_bytes = None
|
|
||||||
if self.discord_reader.guild and self.discord_reader.guild.banner:
|
|
||||||
banner_bytes = await self.discord_reader.download_asset(self.discord_reader.guild.banner)
|
|
||||||
|
|
||||||
if banner_bytes:
|
|
||||||
await self.fluxer_writer.update_guild_metadata(banner=banner_bytes)
|
|
||||||
await progress_callback("Server Banner", "DONE")
|
|
||||||
else:
|
|
||||||
await progress_callback("Server Banner", "SKIP")
|
|
||||||
except Exception:
|
|
||||||
await progress_callback("Server Banner", "ERROR")
|
|
||||||
|
|
||||||
async def sync_channel_state(self):
|
|
||||||
"""
|
|
||||||
Scans Fluxer for channels matching Discord names and updates state.json mappings.
|
|
||||||
This prevents duplicate creation when the state.json is empty but channels exist in Fluxer.
|
|
||||||
"""
|
|
||||||
categories = await self.discord_reader.get_categories()
|
|
||||||
channels = await self.discord_reader.get_channels()
|
|
||||||
fluxer_channels = await self.fluxer_writer.get_channels()
|
|
||||||
|
|
||||||
# Build name -> id map and ID set for Fluxer for fast lookup
|
|
||||||
fluxer_name_map = {c.get("name"): str(c.get("id")) for c in fluxer_channels if c.get("name")}
|
|
||||||
fluxer_id_set = {str(c.get("id")) for c in fluxer_channels}
|
|
||||||
|
|
||||||
updates = 0
|
|
||||||
removals = 0
|
|
||||||
|
|
||||||
# 1. Verify and Sync Categories
|
|
||||||
for cat in categories:
|
|
||||||
discord_id = str(cat.id)
|
|
||||||
fluxer_id = self.state.get_fluxer_category_id(discord_id)
|
|
||||||
|
|
||||||
if fluxer_id:
|
|
||||||
if fluxer_id not in fluxer_id_set:
|
|
||||||
self.state.remove_category_mapping(discord_id)
|
|
||||||
removals += 1
|
|
||||||
elif cat.name in fluxer_name_map:
|
|
||||||
self.state.set_category_mapping(discord_id, fluxer_name_map[cat.name])
|
|
||||||
updates += 1
|
|
||||||
|
|
||||||
# 2. Verify and Sync Channels
|
|
||||||
for ch in channels:
|
|
||||||
discord_id = str(ch.id)
|
|
||||||
fluxer_id = self.state.get_fluxer_channel_id(discord_id)
|
|
||||||
|
|
||||||
if fluxer_id:
|
|
||||||
if fluxer_id not in fluxer_id_set:
|
|
||||||
self.state.remove_channel_mapping(discord_id)
|
|
||||||
removals += 1
|
|
||||||
elif ch.name in fluxer_name_map:
|
|
||||||
self.state.set_channel_mapping(discord_id, fluxer_name_map[ch.name])
|
|
||||||
updates += 1
|
|
||||||
|
|
||||||
if updates > 0 or removals > 0:
|
|
||||||
logger.info(f"Channel sync: {updates} mapped, {removals} stale mappings removed")
|
|
||||||
|
|
||||||
async def sync_roles_state(self):
|
|
||||||
"""
|
|
||||||
Scans Fluxer for roles matching Discord names and updates state.json mappings.
|
|
||||||
"""
|
|
||||||
discord_roles = await self.discord_reader.get_roles()
|
|
||||||
fluxer_roles = await self.fluxer_writer.client.get_guild_roles(self.config.fluxer_community_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")}
|
|
||||||
fluxer_role_ids = {str(r.get("id")) for r in fluxer_roles}
|
|
||||||
|
|
||||||
updates = 0
|
|
||||||
removals = 0
|
|
||||||
|
|
||||||
# Verify and Sync Roles
|
|
||||||
for role in discord_roles:
|
|
||||||
discord_id = str(role.id)
|
|
||||||
fluxer_id = self.state.get_fluxer_role_id(discord_id)
|
|
||||||
|
|
||||||
if fluxer_id:
|
|
||||||
if fluxer_id not in fluxer_role_ids:
|
|
||||||
self.state.remove_role_mapping(discord_id)
|
|
||||||
removals += 1
|
|
||||||
elif role.name in fluxer_role_map:
|
|
||||||
self.state.set_role_mapping(discord_id, fluxer_role_map[role.name])
|
|
||||||
updates += 1
|
|
||||||
|
|
||||||
if updates > 0 or removals > 0:
|
|
||||||
logger.info(f"Role sync: {updates} mapped, {removals} stale mappings removed")
|
|
||||||
|
|
||||||
async def sync_assets_state(self):
|
|
||||||
"""
|
|
||||||
Scans Fluxer for emojis and stickers matching Discord names and updates state.json mappings.
|
|
||||||
"""
|
|
||||||
discord_emojis = await self.discord_reader.get_emojis()
|
|
||||||
discord_stickers = await self.discord_reader.get_stickers()
|
|
||||||
|
|
||||||
fluxer_emojis = await self.fluxer_writer.client.get_guild_emojis(self.config.fluxer_community_id)
|
|
||||||
fluxer_stickers = await self.fluxer_writer.client.get_guild_stickers(self.config.fluxer_community_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")}
|
|
||||||
fluxer_sticker_map = {s.get("name"): str(s.get("id")) for s in fluxer_stickers if s.get("name")}
|
|
||||||
fluxer_emoji_ids = {str(e.get("id")) for e in fluxer_emojis}
|
|
||||||
fluxer_sticker_ids = {str(s.get("id")) for s in fluxer_stickers}
|
|
||||||
|
|
||||||
updates = 0
|
|
||||||
removals = 0
|
|
||||||
|
|
||||||
# 1. Verify and Sync Emojis
|
|
||||||
for emoji in discord_emojis:
|
|
||||||
discord_id = str(emoji.id)
|
|
||||||
fluxer_id = self.state.get_fluxer_emoji_id(discord_id)
|
|
||||||
|
|
||||||
if fluxer_id:
|
|
||||||
if fluxer_id not in fluxer_emoji_ids:
|
|
||||||
self.state.remove_emoji_mapping(discord_id)
|
|
||||||
removals += 1
|
|
||||||
elif emoji.name in fluxer_emoji_map:
|
|
||||||
self.state.set_emoji_mapping(discord_id, fluxer_emoji_map[emoji.name])
|
|
||||||
updates += 1
|
|
||||||
|
|
||||||
# 2. Verify and Sync Stickers
|
|
||||||
for sticker in discord_stickers:
|
|
||||||
discord_id = str(sticker.id)
|
|
||||||
fluxer_id = self.state.get_fluxer_sticker_id(discord_id)
|
|
||||||
|
|
||||||
if fluxer_id:
|
|
||||||
if fluxer_id not in fluxer_sticker_ids:
|
|
||||||
self.state.remove_sticker_mapping(discord_id)
|
|
||||||
removals += 1
|
|
||||||
elif sticker.name in fluxer_sticker_map:
|
|
||||||
self.state.set_sticker_mapping(discord_id, fluxer_sticker_map[sticker.name])
|
|
||||||
updates += 1
|
|
||||||
|
|
||||||
if updates > 0 or removals > 0:
|
|
||||||
logger.info(f"Asset sync: {updates} mapped, {removals} stale mappings removed")
|
|
||||||
|
|
||||||
async def migrate_channels(self, progress_callback: Callable[[str, str, int, int], Awaitable[None]] | None = None, force: bool = False):
|
|
||||||
"""Clones categories and text channels.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
progress_callback: Optional callback receiving (item_name, status, current, total)
|
|
||||||
force: If True, re-create channels even if they exist in state.
|
|
||||||
"""
|
|
||||||
categories = await self.discord_reader.get_categories()
|
|
||||||
channels = await self.discord_reader.get_channels()
|
|
||||||
|
|
||||||
# 1. Identify categories to create
|
|
||||||
missing_categories = [cat for cat in categories if force or not self.state.get_fluxer_category_id(str(cat.id))]
|
|
||||||
missing_category_ids = {str(cat.id) for cat in missing_categories}
|
|
||||||
|
|
||||||
# 2. Identify channels to create or move
|
|
||||||
# Fetch current Fluxer state to check parent_ids
|
|
||||||
fluxer_channels = await self.fluxer_writer.get_channels()
|
|
||||||
fluxer_parent_map = {str(c["id"]): (str(c.get("parent_id")) if c.get("parent_id") else None) for c in fluxer_channels}
|
|
||||||
|
|
||||||
channels_to_create = []
|
|
||||||
channels_to_move = []
|
|
||||||
|
|
||||||
for ch in channels:
|
|
||||||
discord_id = str(ch.id)
|
|
||||||
fluxer_id = self.state.get_fluxer_channel_id(discord_id)
|
|
||||||
|
|
||||||
if force or not fluxer_id:
|
|
||||||
# We'll resolve the parent_id in the loop after categories are created
|
|
||||||
channels_to_create.append(ch)
|
|
||||||
else:
|
|
||||||
# Always add to move/sync list to ensure properties (topic, nsfw, slowmode) are synced
|
|
||||||
# even if the parent category is already correct.
|
|
||||||
channels_to_move.append((ch, fluxer_id))
|
|
||||||
|
|
||||||
total = len(missing_categories) + len(channels_to_create) + len(channels_to_move)
|
|
||||||
current_idx = 0
|
|
||||||
|
|
||||||
if total == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Migrate Categories first
|
|
||||||
for cat in missing_categories:
|
|
||||||
if not self.is_running: break
|
|
||||||
|
|
||||||
state_key = str(cat.id)
|
|
||||||
fluxer_id = await self.fluxer_writer.create_channel(cat.name, type=4)
|
|
||||||
self.state.set_category_mapping(state_key, fluxer_id)
|
|
||||||
|
|
||||||
current_idx += 1
|
|
||||||
if progress_callback: await progress_callback(f"Cat: {cat.name}", "Copying", current_idx, total)
|
|
||||||
await asyncio.sleep(self.config.migration.rate_limit_delay_seconds)
|
|
||||||
|
|
||||||
# Create missing channels
|
|
||||||
for channel in channels_to_create:
|
|
||||||
if not self.is_running: break
|
|
||||||
|
|
||||||
state_key = str(channel.id)
|
|
||||||
topic = getattr(channel, 'topic', "") or ""
|
|
||||||
nsfw = getattr(channel, 'nsfw', False)
|
|
||||||
slowmode = getattr(channel, 'slowmode_delay', 0)
|
|
||||||
|
|
||||||
logger.debug(f"Creating channel {channel.name}: topic={topic}, nsfw={nsfw}, slowmode={slowmode}")
|
|
||||||
|
|
||||||
parent_id = self.state.get_fluxer_category_id(str(channel.category_id)) if channel.category_id else None
|
|
||||||
|
|
||||||
fluxer_id = await self.fluxer_writer.create_channel(
|
|
||||||
name=channel.name,
|
|
||||||
topic=topic,
|
|
||||||
type=0,
|
|
||||||
parent_id=parent_id,
|
|
||||||
nsfw=nsfw,
|
|
||||||
slowmode_delay=slowmode
|
|
||||||
)
|
|
||||||
self.state.set_channel_mapping(state_key, fluxer_id)
|
|
||||||
|
|
||||||
# Sync again immediately because some properties (like slowmode) are ignored on creation
|
|
||||||
await self.fluxer_writer.modify_channel(
|
|
||||||
channel_id=fluxer_id,
|
|
||||||
parent_id=parent_id,
|
|
||||||
name=channel.name,
|
|
||||||
topic=topic,
|
|
||||||
nsfw=nsfw,
|
|
||||||
slowmode_delay=slowmode
|
|
||||||
)
|
|
||||||
|
|
||||||
current_idx += 1
|
|
||||||
if progress_callback: await progress_callback(channel.name, "Copying", current_idx, total)
|
|
||||||
await asyncio.sleep(self.config.migration.rate_limit_delay_seconds)
|
|
||||||
|
|
||||||
# Move/Sync existing channels
|
|
||||||
for channel, fluxer_id in channels_to_move:
|
|
||||||
if not self.is_running: break
|
|
||||||
|
|
||||||
parent_id = self.state.get_fluxer_category_id(str(channel.category_id)) if channel.category_id else None
|
|
||||||
nsfw = getattr(channel, 'nsfw', False)
|
|
||||||
slowmode = getattr(channel, 'slowmode_delay', 0)
|
|
||||||
topic = getattr(channel, 'topic', "") or ""
|
|
||||||
|
|
||||||
logger.debug(f"Syncing existing channel {channel.name} ({fluxer_id}): topic={topic}, nsfw={nsfw}, slowmode={slowmode}")
|
|
||||||
|
|
||||||
await self.fluxer_writer.modify_channel(
|
|
||||||
channel_id=fluxer_id,
|
|
||||||
parent_id=parent_id,
|
|
||||||
name=channel.name,
|
|
||||||
topic=topic,
|
|
||||||
nsfw=nsfw,
|
|
||||||
slowmode_delay=slowmode
|
|
||||||
)
|
|
||||||
|
|
||||||
current_idx += 1
|
|
||||||
if progress_callback: await progress_callback(channel.name, "Syncing", current_idx, total)
|
|
||||||
await asyncio.sleep(self.config.migration.rate_limit_delay_seconds)
|
|
||||||
|
|
||||||
async def sync_permissions(self, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None):
|
|
||||||
"""Syncs category and channel role overrides/permissions."""
|
|
||||||
categories = await self.discord_reader.get_categories()
|
|
||||||
channels = await self.discord_reader.get_channels()
|
|
||||||
|
|
||||||
# Only sync for items that are already mapped
|
|
||||||
categories = [c for c in categories if self.state.get_fluxer_category_id(str(c.id))]
|
|
||||||
channels = [c for c in channels if self.state.get_fluxer_channel_id(str(c.id))]
|
|
||||||
|
|
||||||
total = len(categories) + len(channels)
|
|
||||||
current_idx = 0
|
|
||||||
|
|
||||||
if total == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
async def _sync_overwrites(discord_item, fluxer_id):
|
|
||||||
"""Helper to sync role overwrites for a given channel or category."""
|
|
||||||
for target, overwrite in discord_item.overwrites.items():
|
|
||||||
if type(target).__name__ == "Role":
|
|
||||||
discord_role_id = str(target.id)
|
|
||||||
# Handle @everyone role special case
|
|
||||||
if discord_role_id == self.config.discord_server_id:
|
|
||||||
fluxer_role_id = self.config.fluxer_community_id
|
|
||||||
else:
|
|
||||||
fluxer_role_id = self.state.get_fluxer_role_id(discord_role_id)
|
|
||||||
|
|
||||||
if not fluxer_role_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
allow_val, deny_val = overwrite.pair()
|
|
||||||
await self.fluxer_writer.set_channel_permission(
|
|
||||||
channel_id=fluxer_id,
|
|
||||||
overwrite_id=fluxer_role_id,
|
|
||||||
allow=allow_val.value,
|
|
||||||
deny=deny_val.value,
|
|
||||||
is_role=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sync Category Permissions (Role Overwrites)
|
|
||||||
for cat in categories:
|
|
||||||
if not self.is_running: break
|
|
||||||
fluxer_id = self.state.get_fluxer_category_id(str(cat.id))
|
|
||||||
if fluxer_id:
|
|
||||||
try:
|
|
||||||
await _sync_overwrites(cat, fluxer_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed syncing permissions for category {cat.name}: {e}")
|
|
||||||
|
|
||||||
current_idx += 1
|
|
||||||
if progress_callback: await progress_callback(f"Cat: {cat.name}", current_idx, total)
|
|
||||||
|
|
||||||
# Sync Channel Permissions
|
|
||||||
for channel in channels:
|
|
||||||
if not self.is_running: break
|
|
||||||
fluxer_id = self.state.get_fluxer_channel_id(str(channel.id))
|
|
||||||
if fluxer_id:
|
|
||||||
try:
|
|
||||||
await _sync_overwrites(channel, fluxer_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed syncing permissions for channel {channel.name}: {e}")
|
|
||||||
|
|
||||||
current_idx += 1
|
|
||||||
if progress_callback: await progress_callback(channel.name, current_idx, total)
|
|
||||||
|
|
||||||
async def analyze_migration(self, source_channel_id: int, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None) -> Dict[str, int]:
|
|
||||||
"""
|
|
||||||
Scans channel history to count messages, threads, and attachments.
|
|
||||||
"""
|
|
||||||
stats = {"messages": 0, "threads": 0, "attachments": 0}
|
|
||||||
|
|
||||||
async for msg in self.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id):
|
|
||||||
if not self.is_running:
|
|
||||||
break
|
|
||||||
|
|
||||||
stats["messages"] += 1
|
|
||||||
stats["attachments"] += len(msg.attachments)
|
|
||||||
|
|
||||||
# Count thread messages and markers
|
|
||||||
if hasattr(msg, 'thread') and msg.thread:
|
|
||||||
stats["threads"] += 1
|
|
||||||
# Recursively count thread content
|
|
||||||
thread_stats = await self.analyze_migration(msg.thread.id)
|
|
||||||
stats["messages"] += thread_stats["messages"]
|
|
||||||
stats["attachments"] += thread_stats["attachments"]
|
|
||||||
stats["threads"] += thread_stats["threads"] # Nested threads (rare in Discord but possible in forum channels)
|
|
||||||
|
|
||||||
if progress_callback and stats["messages"] % 10 == 0:
|
|
||||||
await progress_callback(stats["messages"])
|
|
||||||
|
|
||||||
return stats
|
|
||||||
|
|
||||||
async def migrate_messages(self, source_channel_id: int, target_channel_id: str, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None):
|
|
||||||
"""Migrate messages for a specific channel."""
|
|
||||||
message_count = 0
|
|
||||||
async for msg in self.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id):
|
|
||||||
if not self.is_running:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Process attachments
|
|
||||||
files = []
|
|
||||||
attachments_to_process = list(msg.attachments)
|
|
||||||
|
|
||||||
# Check if this message is forwarded
|
|
||||||
# Discord flags: forwarded (is bit 28 / 0x10000000)
|
|
||||||
is_forwarded = False
|
|
||||||
if hasattr(msg.flags, 'forwarded'):
|
|
||||||
is_forwarded = msg.flags.forwarded
|
|
||||||
|
|
||||||
# If forwarded, the content and attachments might be in message_snapshots (discord.py 2.5+)
|
|
||||||
content = msg.content
|
|
||||||
if is_forwarded:
|
|
||||||
logger.debug(f"Detected forwarded message: ID={msg.id}, Flags={msg.flags.value}")
|
|
||||||
if hasattr(msg, 'message_snapshots') and msg.message_snapshots:
|
|
||||||
# For now we handle the first snapshot
|
|
||||||
snapshot = msg.message_snapshots[0]
|
|
||||||
if not content:
|
|
||||||
content = snapshot.content
|
|
||||||
# Add snapshot attachments to the list to process
|
|
||||||
attachments_to_process.extend(snapshot.attachments)
|
|
||||||
logger.debug(f"Found forwarded snapshot content: {content[:50]}... and {len(snapshot.attachments)} attachments")
|
|
||||||
|
|
||||||
for att in attachments_to_process:
|
|
||||||
try:
|
|
||||||
att_data = await self.discord_reader.download_attachment(att)
|
|
||||||
files.append({"filename": att.filename, "data": att_data})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to download attachment {att.filename}: {e}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if this message is a reply
|
|
||||||
reply_to_fluxer_id = None
|
|
||||||
if msg.reference and msg.reference.message_id:
|
|
||||||
reply_to_fluxer_id = self.state.get_fluxer_message_id(str(msg.reference.message_id))
|
|
||||||
|
|
||||||
fluxer_msg_id = await self.fluxer_writer.send_message(
|
|
||||||
channel_id=target_channel_id,
|
|
||||||
author_name=msg.author.display_name,
|
|
||||||
author_avatar_url=str(msg.author.display_avatar.url),
|
|
||||||
content=content,
|
|
||||||
timestamp=msg.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
files=files if files else None,
|
|
||||||
reply_to_message_id=reply_to_fluxer_id,
|
|
||||||
is_forwarded=is_forwarded
|
|
||||||
)
|
|
||||||
|
|
||||||
if fluxer_msg_id:
|
|
||||||
self.state.set_message_mapping(str(msg.id), fluxer_msg_id)
|
|
||||||
|
|
||||||
# Check for associated thread
|
|
||||||
if hasattr(msg, 'thread') and msg.thread:
|
|
||||||
thread = msg.thread
|
|
||||||
logger.info(f"Detected thread '{thread.name}' on message {msg.id}")
|
|
||||||
|
|
||||||
# Send Start Marker
|
|
||||||
await self.fluxer_writer.send_marker(
|
|
||||||
channel_id=target_channel_id,
|
|
||||||
content=f"> <<< THREAD: **{thread.name}** >>>"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Migrate thread messages
|
|
||||||
# We don't pass a progress callback here to avoid confusing the UI
|
|
||||||
# but we do want to track count if possible.
|
|
||||||
await self.migrate_messages(
|
|
||||||
source_channel_id=thread.id,
|
|
||||||
target_channel_id=target_channel_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send End Marker
|
|
||||||
await self.fluxer_writer.send_marker(
|
|
||||||
channel_id=target_channel_id,
|
|
||||||
content=f"> <<< END OF THREAD >>>"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.state.update_last_message_timestamp(str(source_channel_id), str(msg.created_at))
|
|
||||||
message_count += 1
|
|
||||||
if progress_callback:
|
|
||||||
await progress_callback(message_count)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to process message {msg.id}: {e}")
|
|
||||||
import traceback
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
|
|
||||||
# Delay for rate limit safety
|
|
||||||
await asyncio.sleep(self.config.migration.rate_limit_delay_seconds)
|
|
||||||
|
|
||||||
return message_count
|
|
||||||
|
|
||||||
async def migrate_roles(self, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None, force: bool = False):
|
|
||||||
"""Copies roles and their baseline permissions."""
|
|
||||||
roles = await self.discord_reader.get_roles()
|
|
||||||
|
|
||||||
if not force:
|
|
||||||
roles = [r for r in roles if not self.state.get_fluxer_role_id(str(r.id))]
|
|
||||||
|
|
||||||
total = len(roles)
|
|
||||||
|
|
||||||
if total == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
for idx, role in enumerate(roles):
|
|
||||||
if not self.is_running: break
|
|
||||||
|
|
||||||
fluxer_id = await self.fluxer_writer.create_role(
|
|
||||||
name=role.name,
|
|
||||||
color=role.color.value,
|
|
||||||
hoist=role.hoist,
|
|
||||||
mentionable=role.mentionable
|
|
||||||
)
|
|
||||||
if fluxer_id:
|
|
||||||
self.state.set_role_mapping(str(role.id), fluxer_id)
|
|
||||||
|
|
||||||
if progress_callback: await progress_callback(role.name, idx + 1, total)
|
|
||||||
await asyncio.sleep(self.config.migration.rate_limit_delay_seconds)
|
|
||||||
|
|
||||||
async def migrate_emojis(self, progress_callback: Callable[[str, str, int, int], Awaitable[None]] | None = None, types_to_include: List[str] = ["Emoji", "Sticker"], force: bool = False):
|
|
||||||
"""Copies custom emojis and stickers.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
force: If True, skip state cache and re-copy even if already migrated.
|
|
||||||
"""
|
|
||||||
objs = []
|
|
||||||
if "Emoji" in types_to_include:
|
|
||||||
emojis = await self.discord_reader.get_emojis()
|
|
||||||
objs.extend([(e, "Emoji") for e in emojis])
|
|
||||||
if "Sticker" in types_to_include:
|
|
||||||
stickers = await self.discord_reader.get_stickers()
|
|
||||||
objs.extend([(s, "Sticker") for s in stickers])
|
|
||||||
|
|
||||||
if not force:
|
|
||||||
objs = [(obj, obj_type) for obj, obj_type in objs if not (
|
|
||||||
self.state.get_fluxer_emoji_id(str(obj.id)) if obj_type == "Emoji" else self.state.get_fluxer_sticker_id(str(obj.id))
|
|
||||||
)]
|
|
||||||
|
|
||||||
total = len(objs)
|
|
||||||
|
|
||||||
if total == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
for idx, (obj, obj_type) in enumerate(objs):
|
|
||||||
if not self.is_running: break
|
|
||||||
|
|
||||||
try:
|
|
||||||
if obj_type == "Emoji":
|
|
||||||
img_data = await self.discord_reader.download_emoji(obj)
|
|
||||||
fluxer_id = await self.fluxer_writer.create_emoji(
|
|
||||||
name=obj.name,
|
|
||||||
image_bytes=img_data
|
|
||||||
)
|
|
||||||
if fluxer_id:
|
|
||||||
self.state.set_emoji_mapping(str(obj.id), fluxer_id)
|
|
||||||
else:
|
|
||||||
img_data = await self.discord_reader.download_sticker(obj)
|
|
||||||
fluxer_id = await self.fluxer_writer.create_sticker(
|
|
||||||
name=obj.name,
|
|
||||||
image_bytes=img_data
|
|
||||||
)
|
|
||||||
if fluxer_id:
|
|
||||||
self.state.set_sticker_mapping(str(obj.id), fluxer_id)
|
|
||||||
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(self.config.migration.rate_limit_delay_seconds)
|
|
||||||
|
|
||||||
async def run_full_migration(self):
|
|
||||||
self.is_running = True
|
|
||||||
try:
|
|
||||||
await self.start_connections()
|
|
||||||
await self.migrate_channels()
|
|
||||||
|
|
||||||
# Example: just migrate one channel's messages to test
|
|
||||||
channels = await self.discord_reader.get_channels()
|
|
||||||
if channels:
|
|
||||||
await self.migrate_messages(channels[0].id)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await self.close_connections()
|
|
||||||
self.is_running = False
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
self.is_running = False
|
|
||||||
|
|
||||||
# ──────────────── DANGER ZONE ────────────────
|
|
||||||
|
|
||||||
async def danger_remove_logo_and_banner(self) -> dict:
|
|
||||||
"""Removes the community logo and banner image. Returns per-field status."""
|
|
||||||
return await self.fluxer_writer.remove_community_logo_and_banner()
|
|
||||||
|
|
||||||
async def danger_delete_all_channels(self, progress_callback=None) -> int:
|
|
||||||
"""Deletes every channel and category in the Fluxer community."""
|
|
||||||
count = await self.fluxer_writer.delete_all_channels(progress_callback=progress_callback)
|
|
||||||
self.state.clear_channel_mappings()
|
|
||||||
self.state.clear_message_history()
|
|
||||||
return count
|
|
||||||
|
|
||||||
async def danger_reset_channel_permissions(self, progress_callback=None) -> int:
|
|
||||||
"""Resets all permission overwrites on every channel and category."""
|
|
||||||
return await self.fluxer_writer.reset_channel_permissions(progress_callback=progress_callback)
|
|
||||||
|
|
||||||
async def danger_delete_all_roles(self, progress_callback=None) -> int:
|
|
||||||
"""Deletes all deletable roles (skips managed/bot roles and @everyone)."""
|
|
||||||
count = await self.fluxer_writer.delete_all_roles(progress_callback=progress_callback)
|
|
||||||
self.state.clear_role_mappings()
|
|
||||||
return count
|
|
||||||
|
|
||||||
async def danger_delete_all_emojis_and_stickers(self, progress_callback=None) -> dict:
|
|
||||||
"""Deletes all custom emojis and stickers. Returns {"emojis": int, "stickers": int}."""
|
|
||||||
counts = await self.fluxer_writer.delete_all_emojis_and_stickers(progress_callback=progress_callback)
|
|
||||||
self.state.clear_asset_mappings()
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
@ -246,7 +246,7 @@ class FluxerWriter:
|
||||||
if webhook and not files and not reply_to_message_id:
|
if webhook and not files and not reply_to_message_id:
|
||||||
msg = await webhook.send(
|
msg = await webhook.send(
|
||||||
content=final_content,
|
content=final_content,
|
||||||
username=f"{author_name} (via Discord)",
|
username=f"{author_name} (discord)",
|
||||||
avatar_url=author_avatar_url,
|
avatar_url=author_avatar_url,
|
||||||
wait=True
|
wait=True
|
||||||
)
|
)
|
||||||
132
src/core/migrate_message.py
Normal file
132
src/core/migrate_message.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Callable, Awaitable, Dict
|
||||||
|
|
||||||
|
from src.core.base import MigrationContext
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def analyze_migration(context: MigrationContext, source_channel_id: int, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Scans channel history to count messages, threads, and attachments.
|
||||||
|
"""
|
||||||
|
stats = {"messages": 0, "threads": 0, "attachments": 0}
|
||||||
|
|
||||||
|
async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id):
|
||||||
|
if not context.is_running:
|
||||||
|
break
|
||||||
|
|
||||||
|
stats["messages"] += 1
|
||||||
|
stats["attachments"] += len(msg.attachments)
|
||||||
|
|
||||||
|
# Count thread messages and markers
|
||||||
|
if hasattr(msg, 'thread') and msg.thread:
|
||||||
|
stats["threads"] += 1
|
||||||
|
# Recursively count thread content
|
||||||
|
thread_stats = await analyze_migration(context, msg.thread.id)
|
||||||
|
stats["messages"] += thread_stats["messages"]
|
||||||
|
stats["attachments"] += thread_stats["attachments"]
|
||||||
|
stats["threads"] += thread_stats["threads"] # Nested threads (rare in Discord but possible in forum channels)
|
||||||
|
|
||||||
|
if progress_callback and stats["messages"] % 10 == 0:
|
||||||
|
await progress_callback(stats["messages"])
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_messages(context: MigrationContext, source_channel_id: int, target_channel_id: str, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None):
|
||||||
|
"""Migrate messages for a specific channel."""
|
||||||
|
message_count = 0
|
||||||
|
async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id):
|
||||||
|
if not context.is_running:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Process attachments
|
||||||
|
files = []
|
||||||
|
attachments_to_process = list(msg.attachments)
|
||||||
|
|
||||||
|
# Check if this message is forwarded
|
||||||
|
# Discord flags: forwarded (is bit 28 / 0x10000000)
|
||||||
|
is_forwarded = False
|
||||||
|
if hasattr(msg.flags, 'forwarded'):
|
||||||
|
is_forwarded = msg.flags.forwarded
|
||||||
|
|
||||||
|
# If forwarded, the content and attachments might be in message_snapshots (discord.py 2.5+)
|
||||||
|
content = msg.content
|
||||||
|
if is_forwarded:
|
||||||
|
logger.debug(f"Detected forwarded message: ID={msg.id}, Flags={msg.flags.value}")
|
||||||
|
if hasattr(msg, 'message_snapshots') and msg.message_snapshots:
|
||||||
|
# For now we handle the first snapshot
|
||||||
|
snapshot = msg.message_snapshots[0]
|
||||||
|
if not content:
|
||||||
|
content = snapshot.content
|
||||||
|
# Add snapshot attachments to the list to process
|
||||||
|
attachments_to_process.extend(snapshot.attachments)
|
||||||
|
logger.debug(f"Found forwarded snapshot content: {content[:50]}... and {len(snapshot.attachments)} attachments")
|
||||||
|
|
||||||
|
for att in attachments_to_process:
|
||||||
|
try:
|
||||||
|
att_data = await context.discord_reader.download_attachment(att)
|
||||||
|
files.append({"filename": att.filename, "data": att_data})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to download attachment {att.filename}: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if this message is a reply
|
||||||
|
reply_to_fluxer_id = None
|
||||||
|
if msg.reference and msg.reference.message_id:
|
||||||
|
reply_to_fluxer_id = context.state.get_fluxer_message_id(str(msg.reference.message_id))
|
||||||
|
|
||||||
|
fluxer_msg_id = await context.fluxer_writer.send_message(
|
||||||
|
channel_id=target_channel_id,
|
||||||
|
author_name=msg.author.display_name,
|
||||||
|
author_avatar_url=str(msg.author.display_avatar.url),
|
||||||
|
content=content,
|
||||||
|
timestamp=msg.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
files=files if files else None,
|
||||||
|
reply_to_message_id=reply_to_fluxer_id,
|
||||||
|
is_forwarded=is_forwarded
|
||||||
|
)
|
||||||
|
|
||||||
|
if fluxer_msg_id:
|
||||||
|
context.state.set_message_mapping(str(msg.id), fluxer_msg_id)
|
||||||
|
|
||||||
|
# Check for associated thread
|
||||||
|
if hasattr(msg, 'thread') and msg.thread:
|
||||||
|
thread = msg.thread
|
||||||
|
logger.info(f"Detected thread '{thread.name}' on message {msg.id}")
|
||||||
|
|
||||||
|
# Send Start Marker
|
||||||
|
await context.fluxer_writer.send_marker(
|
||||||
|
channel_id=target_channel_id,
|
||||||
|
content=f"> <<< THREAD: **{thread.name}** >>>"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Migrate thread messages
|
||||||
|
# We don't pass a progress callback here to avoid confusing the UI
|
||||||
|
# but we do want to track count if possible.
|
||||||
|
await migrate_messages(
|
||||||
|
context=context,
|
||||||
|
source_channel_id=thread.id,
|
||||||
|
target_channel_id=target_channel_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send End Marker
|
||||||
|
await context.fluxer_writer.send_marker(
|
||||||
|
channel_id=target_channel_id,
|
||||||
|
content=f"> <<< END OF THREAD >>>"
|
||||||
|
)
|
||||||
|
|
||||||
|
context.state.update_last_message_timestamp(str(source_channel_id), str(msg.created_at))
|
||||||
|
message_count += 1
|
||||||
|
if progress_callback:
|
||||||
|
await progress_callback(message_count)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to process message {msg.id}: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
# Delay for rate limit safety
|
||||||
|
await asyncio.sleep(context.config.migration.rate_limit_delay_seconds)
|
||||||
|
|
||||||
|
return message_count
|
||||||
130
src/core/roles_permissions.py
Normal file
130
src/core/roles_permissions.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Callable, Awaitable
|
||||||
|
|
||||||
|
from src.core.base import MigrationContext
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def sync_roles_state(context: MigrationContext):
|
||||||
|
"""
|
||||||
|
Scans Fluxer for roles matching Discord names and updates state.json mappings.
|
||||||
|
"""
|
||||||
|
discord_roles = await context.discord_reader.get_roles()
|
||||||
|
fluxer_roles = await context.fluxer_writer.client.get_guild_roles(context.config.fluxer_community_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")}
|
||||||
|
fluxer_role_ids = {str(r.get("id")) for r in fluxer_roles}
|
||||||
|
|
||||||
|
updates = 0
|
||||||
|
removals = 0
|
||||||
|
|
||||||
|
# Verify and Sync Roles
|
||||||
|
for role in discord_roles:
|
||||||
|
discord_id = str(role.id)
|
||||||
|
fluxer_id = context.state.get_fluxer_role_id(discord_id)
|
||||||
|
|
||||||
|
if fluxer_id:
|
||||||
|
if fluxer_id not in fluxer_role_ids:
|
||||||
|
context.state.remove_role_mapping(discord_id)
|
||||||
|
removals += 1
|
||||||
|
elif role.name in fluxer_role_map:
|
||||||
|
context.state.set_role_mapping(discord_id, fluxer_role_map[role.name])
|
||||||
|
updates += 1
|
||||||
|
|
||||||
|
if updates > 0 or removals > 0:
|
||||||
|
logger.info(f"Role sync: {updates} mapped, {removals} stale mappings removed")
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_permissions(context: MigrationContext, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None):
|
||||||
|
"""Syncs category and channel role overrides/permissions."""
|
||||||
|
categories = await context.discord_reader.get_categories()
|
||||||
|
channels = await context.discord_reader.get_channels()
|
||||||
|
|
||||||
|
# Only sync for items that are already mapped
|
||||||
|
categories = [c for c in categories if context.state.get_fluxer_category_id(str(c.id))]
|
||||||
|
channels = [c for c in channels if context.state.get_fluxer_channel_id(str(c.id))]
|
||||||
|
|
||||||
|
total = len(categories) + len(channels)
|
||||||
|
current_idx = 0
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _sync_overwrites(discord_item, fluxer_id):
|
||||||
|
"""Helper to sync role overwrites for a given channel or category."""
|
||||||
|
for target, overwrite in discord_item.overwrites.items():
|
||||||
|
if type(target).__name__ == "Role":
|
||||||
|
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
|
||||||
|
else:
|
||||||
|
fluxer_role_id = context.state.get_fluxer_role_id(discord_role_id)
|
||||||
|
|
||||||
|
if not fluxer_role_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
allow_val, deny_val = overwrite.pair()
|
||||||
|
await context.fluxer_writer.set_channel_permission(
|
||||||
|
channel_id=fluxer_id,
|
||||||
|
overwrite_id=fluxer_role_id,
|
||||||
|
allow=allow_val.value,
|
||||||
|
deny=deny_val.value,
|
||||||
|
is_role=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sync Category Permissions (Role Overwrites)
|
||||||
|
for cat in categories:
|
||||||
|
if not context.is_running: break
|
||||||
|
fluxer_id = context.state.get_fluxer_category_id(str(cat.id))
|
||||||
|
if fluxer_id:
|
||||||
|
try:
|
||||||
|
await _sync_overwrites(cat, fluxer_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed syncing permissions for category {cat.name}: {e}")
|
||||||
|
|
||||||
|
current_idx += 1
|
||||||
|
if progress_callback: await progress_callback(f"Cat: {cat.name}", current_idx, total)
|
||||||
|
|
||||||
|
# Sync Channel Permissions
|
||||||
|
for channel in channels:
|
||||||
|
if not context.is_running: break
|
||||||
|
fluxer_id = context.state.get_fluxer_channel_id(str(channel.id))
|
||||||
|
if fluxer_id:
|
||||||
|
try:
|
||||||
|
await _sync_overwrites(channel, fluxer_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed syncing permissions for channel {channel.name}: {e}")
|
||||||
|
|
||||||
|
current_idx += 1
|
||||||
|
if progress_callback: await progress_callback(channel.name, current_idx, total)
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_roles(context: MigrationContext, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None, force: bool = False):
|
||||||
|
"""Copies roles and their baseline permissions."""
|
||||||
|
roles = await context.discord_reader.get_roles()
|
||||||
|
|
||||||
|
if not force:
|
||||||
|
roles = [r for r in roles if not context.state.get_fluxer_role_id(str(r.id))]
|
||||||
|
|
||||||
|
total = len(roles)
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
for idx, role in enumerate(roles):
|
||||||
|
if not context.is_running: break
|
||||||
|
|
||||||
|
fluxer_id = await context.fluxer_writer.create_role(
|
||||||
|
name=role.name,
|
||||||
|
color=role.color.value,
|
||||||
|
hoist=role.hoist,
|
||||||
|
mentionable=role.mentionable
|
||||||
|
)
|
||||||
|
if fluxer_id:
|
||||||
|
context.state.set_role_mapping(str(role.id), fluxer_id)
|
||||||
|
|
||||||
|
if progress_callback: await progress_callback(role.name, idx + 1, total)
|
||||||
|
await asyncio.sleep(context.config.migration.rate_limit_delay_seconds)
|
||||||
49
src/core/server_metadata.py
Normal file
49
src/core/server_metadata.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import logging
|
||||||
|
from typing import Callable, Awaitable, List
|
||||||
|
|
||||||
|
from src.core.base import MigrationContext
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def sync_server_metadata(context: MigrationContext, progress_callback: Callable[[str, str], Awaitable[None]], components: List[str] = ["name", "icon", "banner"]):
|
||||||
|
"""Syncs the server name, logo and banner."""
|
||||||
|
metadata = await context.discord_reader.get_server_metadata()
|
||||||
|
|
||||||
|
# 1. Sync Name
|
||||||
|
if "name" in components:
|
||||||
|
try:
|
||||||
|
name = metadata.get("name")
|
||||||
|
await context.fluxer_writer.update_guild_metadata(name=name)
|
||||||
|
await progress_callback("Server Name", "DONE")
|
||||||
|
except Exception:
|
||||||
|
await progress_callback("Server Name", "ERROR")
|
||||||
|
|
||||||
|
# 2. Sync Icon
|
||||||
|
if "icon" in components:
|
||||||
|
try:
|
||||||
|
icon_bytes = None
|
||||||
|
if context.discord_reader.guild and context.discord_reader.guild.icon:
|
||||||
|
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 progress_callback("Server Icon", "DONE")
|
||||||
|
else:
|
||||||
|
await progress_callback("Server Icon", "SKIP")
|
||||||
|
except Exception:
|
||||||
|
await progress_callback("Server Icon", "ERROR")
|
||||||
|
|
||||||
|
# 3. Sync Banner
|
||||||
|
if "banner" in components:
|
||||||
|
try:
|
||||||
|
banner_bytes = None
|
||||||
|
if context.discord_reader.guild and context.discord_reader.guild.banner:
|
||||||
|
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 progress_callback("Server Banner", "DONE")
|
||||||
|
else:
|
||||||
|
await progress_callback("Server Banner", "SKIP")
|
||||||
|
except Exception:
|
||||||
|
await progress_callback("Server Banner", "ERROR")
|
||||||
|
|
@ -7,8 +7,14 @@ from rich.prompt import Prompt, Confirm
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
||||||
from src.config import load_config, save_config
|
from src.core.configuration import load_config, save_config
|
||||||
from src.core.engine import MigrationEngine
|
from src.core.base import MigrationContext
|
||||||
|
from src.core.clone_server import sync_channel_state, migrate_channels
|
||||||
|
from src.core.roles_permissions import sync_roles_state, sync_permissions, migrate_roles
|
||||||
|
from src.core.emoji_stickers import sync_assets_state, migrate_emojis
|
||||||
|
from src.core.server_metadata import sync_server_metadata
|
||||||
|
from src.core.migrate_message import analyze_migration, migrate_messages
|
||||||
|
from src.core.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
|
||||||
|
|
||||||
class RateLimitHandler(logging.Handler):
|
class RateLimitHandler(logging.Handler):
|
||||||
"""Intersects library logs to print clean rate limit messages."""
|
"""Intersects library logs to print clean rate limit messages."""
|
||||||
|
|
@ -55,7 +61,7 @@ class MigrationCLI:
|
||||||
console.print(f"[bold red]Failed to load config: {e}[/bold red]")
|
console.print(f"[bold red]Failed to load config: {e}[/bold red]")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
self.engine = MigrationEngine(self.config)
|
self.engine = MigrationContext(self.config)
|
||||||
self.progress_callback_task = None
|
self.progress_callback_task = None
|
||||||
self.tokens_valid = False
|
self.tokens_valid = False
|
||||||
|
|
||||||
|
|
@ -284,7 +290,7 @@ class MigrationCLI:
|
||||||
|
|
||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
# Recreate engine with new config
|
# Recreate engine with new config
|
||||||
self.engine = MigrationEngine(self.config)
|
self.engine = MigrationContext(self.config)
|
||||||
|
|
||||||
# Re-validate
|
# Re-validate
|
||||||
console.print("[yellow]Validating new configuration...[/yellow]")
|
console.print("[yellow]Validating new configuration...[/yellow]")
|
||||||
|
|
@ -309,7 +315,7 @@ class MigrationCLI:
|
||||||
try:
|
try:
|
||||||
await self.engine.start_connections()
|
await self.engine.start_connections()
|
||||||
with console.status("[yellow]Syncing Fluxer channel state...[/yellow]"):
|
with console.status("[yellow]Syncing Fluxer channel state...[/yellow]"):
|
||||||
await self.engine.sync_channel_state()
|
await sync_channel_state(self.engine)
|
||||||
categories = await self.engine.discord_reader.get_categories()
|
categories = await self.engine.discord_reader.get_categories()
|
||||||
channels = await self.engine.discord_reader.get_channels()
|
channels = await self.engine.discord_reader.get_channels()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -408,7 +414,7 @@ class MigrationCLI:
|
||||||
progress.update(channel_task, total=total, completed=current, description=f"[{color}]{status} Channel: {item_name}")
|
progress.update(channel_task, total=total, completed=current, description=f"[{color}]{status} Channel: {item_name}")
|
||||||
|
|
||||||
self.engine.is_running = True
|
self.engine.is_running = True
|
||||||
await self.engine.migrate_channels(progress_callback=update_progress, force=force)
|
await migrate_channels(self.engine, progress_callback=update_progress, force=force)
|
||||||
|
|
||||||
console.print("[bold green]Server Template cloned![/bold green]")
|
console.print("[bold green]Server Template cloned![/bold green]")
|
||||||
|
|
||||||
|
|
@ -434,7 +440,7 @@ class MigrationCLI:
|
||||||
await self.engine.start_connections()
|
await self.engine.start_connections()
|
||||||
|
|
||||||
with console.status("[yellow]Checking Fluxer for existing roles...[/yellow]"):
|
with console.status("[yellow]Checking Fluxer for existing roles...[/yellow]"):
|
||||||
await self.engine.sync_roles_state()
|
await sync_roles_state(self.engine)
|
||||||
|
|
||||||
roles = await self.engine.discord_reader.get_roles()
|
roles = await self.engine.discord_reader.get_roles()
|
||||||
|
|
||||||
|
|
@ -492,7 +498,7 @@ class MigrationCLI:
|
||||||
progress.update(role_task, total=total, completed=current, description=f"[cyan]Syncing Role: {item_name}")
|
progress.update(role_task, total=total, completed=current, description=f"[cyan]Syncing Role: {item_name}")
|
||||||
|
|
||||||
self.engine.is_running = True
|
self.engine.is_running = True
|
||||||
await self.engine.migrate_roles(progress_callback=update_progress, force=force)
|
await migrate_roles(self.engine, progress_callback=update_progress, force=force)
|
||||||
|
|
||||||
console.print("[bold green]Role migration complete![/bold green]")
|
console.print("[bold green]Role migration complete![/bold green]")
|
||||||
|
|
||||||
|
|
@ -538,7 +544,7 @@ class MigrationCLI:
|
||||||
progress.update(perm_task, total=total, completed=current, description=f"[cyan]Syncing: {item_name}")
|
progress.update(perm_task, total=total, completed=current, description=f"[cyan]Syncing: {item_name}")
|
||||||
|
|
||||||
self.engine.is_running = True
|
self.engine.is_running = True
|
||||||
await self.engine.sync_permissions(progress_callback=update_progress)
|
await sync_permissions(self.engine, progress_callback=update_progress)
|
||||||
|
|
||||||
console.print("[bold green]Permission synchronization complete![/bold green]")
|
console.print("[bold green]Permission synchronization complete![/bold green]")
|
||||||
|
|
||||||
|
|
@ -554,7 +560,7 @@ class MigrationCLI:
|
||||||
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("[yellow]Checking Fluxer for existing emojis and stickers...[/yellow]"):
|
||||||
await self.engine.sync_assets_state()
|
await 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()
|
||||||
|
|
@ -642,7 +648,8 @@ 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
|
||||||
await self.engine.migrate_emojis(
|
await migrate_emojis(
|
||||||
|
self.engine,
|
||||||
progress_callback=update_progress,
|
progress_callback=update_progress,
|
||||||
types_to_include=types_to_include,
|
types_to_include=types_to_include,
|
||||||
force=force
|
force=force
|
||||||
|
|
@ -706,7 +713,7 @@ class MigrationCLI:
|
||||||
color = "green" if status == "DONE" else "red" if status == "ERROR" else "yellow"
|
color = "green" if status == "DONE" else "red" if status == "ERROR" else "yellow"
|
||||||
console.print(f"{item} [[bold {color}]{status}[/bold {color}]]")
|
console.print(f"{item} [[bold {color}]{status}[/bold {color}]]")
|
||||||
|
|
||||||
await self.engine.sync_server_metadata(progress_callback, components=components)
|
await sync_server_metadata(self.engine, progress_callback, components=components)
|
||||||
console.print("[bold green]Server metadata sync finished![/bold green]")
|
console.print("[bold green]Server metadata sync finished![/bold green]")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"[bold red]Error during metadata sync: {str(e)}[/bold red]")
|
console.print(f"[bold red]Error during metadata sync: {str(e)}[/bold red]")
|
||||||
|
|
@ -876,7 +883,8 @@ class MigrationCLI:
|
||||||
async def update_scan_progress(count: int):
|
async def update_scan_progress(count: int):
|
||||||
progress.update(task, description=f"[cyan]Scanned {count} items...")
|
progress.update(task, description=f"[cyan]Scanned {count} items...")
|
||||||
|
|
||||||
stats = await self.engine.analyze_migration(
|
stats = await analyze_migration(
|
||||||
|
self.engine,
|
||||||
source_channel_id=source_channel.id,
|
source_channel_id=source_channel.id,
|
||||||
after_message_id=after_id,
|
after_message_id=after_id,
|
||||||
progress_callback=update_scan_progress
|
progress_callback=update_scan_progress
|
||||||
|
|
@ -928,7 +936,8 @@ class MigrationCLI:
|
||||||
async def update_msg_progress(count: int):
|
async def update_msg_progress(count: int):
|
||||||
progress.update(task, description=f"[cyan]Migrated {count} messages...")
|
progress.update(task, description=f"[cyan]Migrated {count} messages...")
|
||||||
|
|
||||||
count = await self.engine.migrate_messages(
|
count = await migrate_messages(
|
||||||
|
self.engine,
|
||||||
source_channel_id=source_channel.id,
|
source_channel_id=source_channel.id,
|
||||||
target_channel_id=target_channel.get("id"),
|
target_channel_id=target_channel.get("id"),
|
||||||
after_message_id=after_id,
|
after_message_id=after_id,
|
||||||
|
|
@ -990,7 +999,7 @@ class MigrationCLI:
|
||||||
progress.update(del_task, total=total, completed=current,
|
progress.update(del_task, total=total, completed=current,
|
||||||
description=f"[red]Deleting: {name}")
|
description=f"[red]Deleting: {name}")
|
||||||
|
|
||||||
count = await self.engine.danger_delete_all_channels(progress_callback=on_channel_deleted)
|
count = await danger_delete_all_channels(self.engine, progress_callback=on_channel_deleted)
|
||||||
console.print(f"[bold green]{count} channels/categories deleted.[/bold green]")
|
console.print(f"[bold green]{count} channels/categories deleted.[/bold green]")
|
||||||
console.print("[bold green]Done.[/bold green]")
|
console.print("[bold green]Done.[/bold green]")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1021,7 +1030,7 @@ class MigrationCLI:
|
||||||
progress.update(perm_task, total=total, completed=current,
|
progress.update(perm_task, total=total, completed=current,
|
||||||
description=f"[red]Resetting: {name}")
|
description=f"[red]Resetting: {name}")
|
||||||
|
|
||||||
count = await self.engine.danger_reset_channel_permissions(progress_callback=on_perm_reset)
|
count = await danger_reset_channel_permissions(self.engine, progress_callback=on_perm_reset)
|
||||||
console.print(f"[bold green]Permissions reset on {count} channels/categories.[/bold green]")
|
console.print(f"[bold green]Permissions reset on {count} channels/categories.[/bold green]")
|
||||||
console.print("[bold green]Done.[/bold green]")
|
console.print("[bold green]Done.[/bold green]")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1053,7 +1062,7 @@ class MigrationCLI:
|
||||||
progress.update(role_task, total=total, completed=current,
|
progress.update(role_task, total=total, completed=current,
|
||||||
description=f"[red]Deleting role: {name}")
|
description=f"[red]Deleting role: {name}")
|
||||||
|
|
||||||
count = await self.engine.danger_delete_all_roles(progress_callback=on_role_deleted)
|
count = await danger_delete_all_roles(self.engine, progress_callback=on_role_deleted)
|
||||||
console.print(f"[bold green]{count} roles deleted.[/bold green]")
|
console.print(f"[bold green]{count} roles deleted.[/bold green]")
|
||||||
console.print("[bold green]Done.[/bold green]")
|
console.print("[bold green]Done.[/bold green]")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1084,7 +1093,7 @@ class MigrationCLI:
|
||||||
progress.update(asset_task, total=total, completed=current,
|
progress.update(asset_task, total=total, completed=current,
|
||||||
description=f"[red]Deleting {asset_type}: {name}")
|
description=f"[red]Deleting {asset_type}: {name}")
|
||||||
|
|
||||||
counts = await self.engine.danger_delete_all_emojis_and_stickers(progress_callback=on_asset_deleted)
|
counts = await danger_delete_all_emojis_and_stickers(self.engine, progress_callback=on_asset_deleted)
|
||||||
console.print(f"[bold green]{counts.get('emojis', 0)} emojis deleted.[/bold green]")
|
console.print(f"[bold green]{counts.get('emojis', 0)} emojis deleted.[/bold green]")
|
||||||
console.print(f"[bold green]{counts.get('stickers', 0)} stickers deleted.[/bold green]")
|
console.print(f"[bold green]{counts.get('stickers', 0)} stickers deleted.[/bold green]")
|
||||||
console.print("[bold green]Done.[/bold green]")
|
console.print("[bold green]Done.[/bold green]")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue