backup server structure
This commit is contained in:
parent
5f9e8c9ed2
commit
5091fc217f
3 changed files with 98 additions and 44 deletions
|
|
@ -1,6 +1,9 @@
|
||||||
import discord
|
import discord
|
||||||
|
import logging
|
||||||
from typing import AsyncGenerator, Dict, Any
|
from typing import AsyncGenerator, Dict, Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class DiscordReader:
|
class DiscordReader:
|
||||||
def __init__(self, token: str, server_id: str):
|
def __init__(self, token: str, server_id: str):
|
||||||
self.token = token
|
self.token = token
|
||||||
|
|
@ -31,11 +34,11 @@ class DiscordReader:
|
||||||
|
|
||||||
# Use fetch methods specifically to bypass dependency on gateway cache
|
# Use fetch methods specifically to bypass dependency on gateway cache
|
||||||
try:
|
try:
|
||||||
logging.info(f"Fetching guild {self.server_id}...")
|
logger.info(f"Fetching guild {self.server_id}...")
|
||||||
self.guild = await self.client.fetch_guild(self.server_id)
|
self.guild = await self.client.fetch_guild(self.server_id)
|
||||||
logging.info(f"Successfully fetched guild: {self.guild.name}")
|
logger.info(f"Successfully fetched guild: {self.guild.name}")
|
||||||
except discord.Forbidden:
|
except discord.Forbidden:
|
||||||
logging.error(f"403 Forbidden: Missing Access to fetch guild {self.server_id}.")
|
logger.error(f"403 Forbidden: Missing Access to fetch guild {self.server_id}.")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Pre-fetch roles via API - Handle Forbidden gracefully
|
# Pre-fetch roles via API - Handle Forbidden gracefully
|
||||||
|
|
@ -43,10 +46,10 @@ class DiscordReader:
|
||||||
roles = await self.guild.fetch_roles()
|
roles = await self.guild.fetch_roles()
|
||||||
self.role_map = {r.id: r.name for r in roles}
|
self.role_map = {r.id: r.name for r in roles}
|
||||||
except discord.Forbidden:
|
except discord.Forbidden:
|
||||||
logging.warning("403 Forbidden: Missing Access to fetch roles. Continuing without role mapping.")
|
logger.warning("403 Forbidden: Missing Access to fetch roles. Continuing without role mapping.")
|
||||||
self.role_map = {}
|
self.role_map = {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to fetch roles: {e}")
|
logger.error(f"Failed to fetch roles: {e}")
|
||||||
self.role_map = {}
|
self.role_map = {}
|
||||||
|
|
||||||
async def validate(self) -> Dict[str, Any]:
|
async def validate(self) -> Dict[str, Any]:
|
||||||
|
|
|
||||||
|
|
@ -120,9 +120,8 @@ class DiscordExporter:
|
||||||
else:
|
else:
|
||||||
logger.info("No server banner found to download.")
|
logger.info("No server banner found to download.")
|
||||||
|
|
||||||
async def export_customization(self):
|
async def export_assets(self):
|
||||||
"""Exports all emojis, stickers, members, plus server icon/banner, to server_media folder."""
|
"""Exports emojis, stickers, and server media to server_assets.json and server_media/."""
|
||||||
# Ensure icon/banner are downloaded
|
|
||||||
await self.download_server_assets()
|
await self.download_server_assets()
|
||||||
|
|
||||||
emojis = await self.reader.get_emojis()
|
emojis = await self.reader.get_emojis()
|
||||||
|
|
@ -174,6 +173,23 @@ class DiscordExporter:
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.error(f"Failed to download sticker {s.name}: {ex}")
|
logger.error(f"Failed to download sticker {s.name}: {ex}")
|
||||||
|
|
||||||
|
# Try to load existing customization to merge (if it exists)
|
||||||
|
custom_file = self.export_path / "server_assets.json"
|
||||||
|
customization = {"emojis": emoji_data, "stickers": sticker_data, "members": []}
|
||||||
|
if custom_file.exists():
|
||||||
|
try:
|
||||||
|
with open(custom_file, "r", encoding="utf-8") as f:
|
||||||
|
old_data = json.load(f)
|
||||||
|
customization["members"] = old_data.get("members", [])
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
|
with open(custom_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(customization, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
return len(emoji_data), len(sticker_data)
|
||||||
|
|
||||||
|
async def export_members(self):
|
||||||
|
"""Exports server members to server_assets.json."""
|
||||||
member_data = []
|
member_data = []
|
||||||
logger.info("Attempting to export members (requires Server Members Intent)...")
|
logger.info("Attempting to export members (requires Server Members Intent)...")
|
||||||
try:
|
try:
|
||||||
|
|
@ -190,20 +206,27 @@ class DiscordExporter:
|
||||||
})
|
})
|
||||||
logger.info(f"Successfully exported {len(member_data)} members.")
|
logger.info(f"Successfully exported {len(member_data)} members.")
|
||||||
except discord.Forbidden:
|
except discord.Forbidden:
|
||||||
logger.warning("403 Forbidden: Missing Access to fetch members. Skipping members export. Ensure 'Server Members Intent' is enabled in Discord Dev Portal.")
|
logger.warning("403 Forbidden: Missing Access to fetch members. Skipping members export.")
|
||||||
|
return 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch members: {e}")
|
logger.error(f"Failed to fetch members: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
customization = {
|
# Merge with existing assets
|
||||||
"emojis": emoji_data,
|
custom_file = self.export_path / "server_assets.json"
|
||||||
"stickers": sticker_data,
|
customization = {"emojis": [], "stickers": [], "members": member_data}
|
||||||
"members": member_data
|
if custom_file.exists():
|
||||||
}
|
try:
|
||||||
|
with open(custom_file, "r", encoding="utf-8") as f:
|
||||||
|
old_data = json.load(f)
|
||||||
|
customization["emojis"] = old_data.get("emojis", [])
|
||||||
|
customization["stickers"] = old_data.get("stickers", [])
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
with open(self.export_path / "customization.json", "w", encoding="utf-8") as f:
|
with open(custom_file, "w", encoding="utf-8") as f:
|
||||||
json.dump(customization, f, indent=4, ensure_ascii=False)
|
json.dump(customization, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
return len(emoji_data), len(sticker_data), len(member_data)
|
return len(member_data)
|
||||||
|
|
||||||
async def export_channels_structure(self):
|
async def export_channels_structure(self):
|
||||||
"""Exports categories and channels hierarchy."""
|
"""Exports categories and channels hierarchy."""
|
||||||
|
|
@ -211,30 +234,39 @@ class DiscordExporter:
|
||||||
channels = await self.reader.get_channels()
|
channels = await self.reader.get_channels()
|
||||||
|
|
||||||
structure = []
|
structure = []
|
||||||
|
chan_count = 0
|
||||||
|
cat_count = len(categories)
|
||||||
|
|
||||||
for cat in categories:
|
for cat in categories:
|
||||||
cat_channels = [c for c in channels if c.category_id == cat.id]
|
cat_channels = [c for c in channels if c.category_id == cat.id]
|
||||||
|
formatted_channels = [self._format_channel(c) for c in cat_channels]
|
||||||
|
chan_count += len(formatted_channels)
|
||||||
structure.append({
|
structure.append({
|
||||||
"type": "category",
|
"type": "category",
|
||||||
"id": str(cat.id),
|
"id": str(cat.id),
|
||||||
"name": cat.name,
|
"name": cat.name,
|
||||||
"position": cat.position,
|
"position": cat.position,
|
||||||
"channels": [self._format_channel(c) for c in cat_channels]
|
"channels": formatted_channels
|
||||||
})
|
})
|
||||||
|
|
||||||
# Uncategorized
|
# Uncategorized
|
||||||
uncategorized = [c for c in channels if not c.category_id]
|
uncategorized = [c for c in channels if not c.category_id]
|
||||||
if uncategorized:
|
if uncategorized:
|
||||||
|
formatted_uncat = [self._format_channel(c) for c in uncategorized]
|
||||||
|
chan_count += len(formatted_uncat)
|
||||||
structure.append({
|
structure.append({
|
||||||
"type": "category",
|
"type": "category",
|
||||||
"id": "uncategorized",
|
"id": "uncategorized",
|
||||||
"name": "Uncategorized",
|
"name": "Uncategorized",
|
||||||
"channels": [self._format_channel(c) for c in uncategorized]
|
"channels": formatted_uncat
|
||||||
})
|
})
|
||||||
|
# No need to increment cat_count for 'Uncategorized' usually,
|
||||||
|
# but let's see if the user wants it. For now, cat_count is real Discord categories.
|
||||||
|
|
||||||
output_file = self.export_path / "channels_structure.json"
|
output_file = self.export_path / "server_structure.json"
|
||||||
with open(output_file, "w", encoding="utf-8") as f:
|
with open(output_file, "w", encoding="utf-8") as f:
|
||||||
json.dump(structure, f, indent=4, ensure_ascii=False)
|
json.dump(structure, f, indent=4, ensure_ascii=False)
|
||||||
return structure
|
return structure, cat_count, chan_count
|
||||||
|
|
||||||
def _format_channel(self, c):
|
def _format_channel(self, c):
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -77,10 +77,14 @@ class ExodusCLI:
|
||||||
d_display = f"[bold green]\"{d_name}\"[/bold green]" if d_name else "[bold red]NOT CONNECTED[/bold red]"
|
d_display = f"[bold green]\"{d_name}\"[/bold green]" if d_name else "[bold red]NOT CONNECTED[/bold red]"
|
||||||
console.print(f"[bold cyan]Source Server:[/bold cyan] {d_display}")
|
console.print(f"[bold cyan]Source Server:[/bold cyan] {d_display}")
|
||||||
|
|
||||||
|
b_name = self.validation_results.get("discord_bot_name")
|
||||||
|
b_display = f"[bold green]\"{b_name}\"[/bold green]" if b_name else "[bold red]UNKNOWN[/bold red]"
|
||||||
|
console.print(f"[bold cyan]Bot name:[/bold cyan] {b_display}")
|
||||||
|
|
||||||
console.print("\n[bold]Main Menu[/bold]")
|
console.print("\n[bold]Main Menu[/bold]")
|
||||||
console.print("(1) Backup Basic Server Profile")
|
console.print("(1) Backup Server Profile")
|
||||||
console.print("(2) Backup User Names & Avatars, other customization")
|
console.print("(2) Backup User Names & Avatars")
|
||||||
console.print("(3) Backup Message")
|
console.print("(3) Backup Messages")
|
||||||
console.print("(4) Update & Sync Backup")
|
console.print("(4) Update & Sync Backup")
|
||||||
console.print("(C) Configuration")
|
console.print("(C) Configuration")
|
||||||
console.print("(Q) Exit")
|
console.print("(Q) Exit")
|
||||||
|
|
@ -102,48 +106,63 @@ class ExodusCLI:
|
||||||
break
|
break
|
||||||
|
|
||||||
async def backup_server_profile(self):
|
async def backup_server_profile(self):
|
||||||
"""Option 1: Backup name, banner, logo and roles (with permissions)"""
|
"""Option 1: Backup name, banner, logo, roles, structure, and custom assets."""
|
||||||
if not self.tokens_valid: return
|
if not self.tokens_valid: return
|
||||||
try:
|
try:
|
||||||
await self.engine.discord_reader.start()
|
await self.engine.discord_reader.start()
|
||||||
# setup folder
|
|
||||||
meta = await self.exporter.setup()
|
meta = await self.exporter.setup()
|
||||||
|
|
||||||
# Export metadata & assets
|
with console.status("[yellow]Backing up server profile & skeleton...[/yellow]"):
|
||||||
with console.status("[yellow]Backing up server profile & assets...[/yellow]"):
|
# 1. Profile & Assets
|
||||||
await self.exporter.export_metadata()
|
await self.exporter.export_metadata()
|
||||||
await self.exporter.download_server_assets()
|
await self.exporter.download_server_assets()
|
||||||
|
|
||||||
# export roles separately to handle 403 gracefully
|
# 2. Roles
|
||||||
try:
|
try:
|
||||||
with console.status("[yellow]Backing up roles...[/yellow]"):
|
role_data = await self.exporter.export_roles()
|
||||||
await self.exporter.export_roles()
|
r_count = len(role_data)
|
||||||
except discord.Forbidden:
|
except discord.Forbidden:
|
||||||
console.print("[yellow]Warning: 403 Forbidden. Could not backup roles (Missing Access).[/yellow]")
|
r_count = 0
|
||||||
|
console.print("[yellow]Warning: Could not backup roles (Missing Access).[/yellow]")
|
||||||
|
|
||||||
console.print(f"[bold green]Basic Server Profile backed up to: {self.exporter.export_path}[/bold green]")
|
# 3. Structure
|
||||||
|
_, cat_count, chan_count = await self.exporter.export_channels_structure()
|
||||||
|
|
||||||
|
# 4. Emojis & Stickers
|
||||||
|
e_count, s_count = await self.exporter.export_assets()
|
||||||
|
|
||||||
|
console.print(f"[bold green]Server Profile backed up to: {self.exporter.export_path}[/bold green]")
|
||||||
|
console.print("\n[bold]Profile Backup Stats:[/bold]")
|
||||||
|
console.print("- name, logo & banner")
|
||||||
|
console.print(f"- {cat_count} categories & {chan_count} channels")
|
||||||
|
console.print(f"- {r_count} roles")
|
||||||
|
console.print(f"- {e_count} emojis, {s_count} stickers.")
|
||||||
except discord.Forbidden as e:
|
except discord.Forbidden as e:
|
||||||
console.print(f"[bold red]Backup failed: {e}[/bold red]")
|
console.print(f"[bold red]Backup failed: {e}[/bold red]")
|
||||||
console.print("[yellow]Hint: This is a 'Missing Access' error. Check if your bot has 'Guilds' permissions enabled.[/yellow]")
|
console.print("[yellow]Hint: Missing permissions. Check bot 'Guilds' and 'Channel' access.[/yellow]")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"[bold red]Backup failed: {e}[/bold red]")
|
console.print(f"[bold red]Backup failed: {e}[/bold red]")
|
||||||
finally:
|
finally:
|
||||||
await self.engine.close_connections()
|
await self.engine.close_connections()
|
||||||
|
|
||||||
async def backup_customization(self):
|
async def backup_customization(self):
|
||||||
"""Option 2: Backup User Names & Avatars, Emojis, Stickers, etc."""
|
"""Option 2: Backup User Names & Avatars (Members)."""
|
||||||
if not self.tokens_valid: return
|
if not self.tokens_valid: return
|
||||||
try:
|
try:
|
||||||
await self.engine.discord_reader.start()
|
await self.engine.discord_reader.start()
|
||||||
await self.exporter.setup()
|
await self.exporter.setup()
|
||||||
with console.status("[yellow]Backing up customization (Members, Emojis & Stickers)...[/yellow]"):
|
with console.status("[yellow]Backing up members and names...[/yellow]"):
|
||||||
e_count, s_count, m_count = await self.exporter.export_customization()
|
m_count = await self.exporter.export_members()
|
||||||
console.print(f"[bold green]Backup complete: {m_count} members, {e_count} emojis and {s_count} stickers saved to customization.json[/bold green]")
|
|
||||||
|
if m_count > 0:
|
||||||
|
console.print(f"[bold green]Backup complete: {m_count} members and avatars saved to customization.json[/bold green]")
|
||||||
|
else:
|
||||||
|
console.print("[yellow]No members exported. Ensure 'Server Members Intent' is enabled.[/yellow]")
|
||||||
except discord.Forbidden as e:
|
except discord.Forbidden as e:
|
||||||
console.print(f"[bold red]Customization backup failed: {e}[/bold red]")
|
console.print(f"[bold red]Member backup failed: {e}[/bold red]")
|
||||||
console.print("[yellow]Hint: Missing Access. Ensure 'Server Members Intent' is enabled in the Discord Developer Portal.[/yellow]")
|
console.print("[yellow]Hint: Missing Access. Ensure 'Server Members Intent' is enabled in the Developer Portal.[/yellow]")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"[bold red]Customization backup failed: {e}[/bold red]")
|
console.print(f"[bold red]Member backup failed: {e}[/bold red]")
|
||||||
finally:
|
finally:
|
||||||
await self.engine.close_connections()
|
await self.engine.close_connections()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue