disco-reaper/src/core/backup_reader.py
2026-04-01 01:34:42 +05:30

1480 lines
52 KiB
Python

"""
BackupReader — discord.py-compatible local data provider.
Reads from local backup JSON files (produced by Reaper) instead of the
Discord API. Implements the same public interface as DiscordReader so that
migration scripts and UI code can use either provider transparently.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from enum import IntEnum
from pathlib import Path
from typing import AsyncGenerator, Dict, Any, List, Optional, Union
from src.core.backup_database import BackupDatabase, parse_snowflake
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Lightweight enum clones (mirror discord.py values for compatibility)
# ---------------------------------------------------------------------------
class ChannelType(IntEnum):
text = 0
private = 1
voice = 2
group = 3
category = 4
news = 5
news_thread = 10
public_thread = 11
private_thread = 12
stage_voice = 13
directory = 14
forum = 15
media = 16
class StickerFormatType(IntEnum):
png = 1
apng = 2
lottie = 3
gif = 4
class MessageType(IntEnum):
default = 0
recipient_add = 1
recipient_remove = 2
call = 3
channel_name_change = 4
channel_icon_change = 5
channel_pinned_message = 6
user_join = 7
guild_boost = 8
guild_boost_tier_1 = 9
guild_boost_tier_2 = 10
guild_boost_tier_3 = 11
channel_follow_add = 12
guild_discovery_disqualified = 14
guild_discovery_requalified = 15
guild_discovery_grace_period_initial_warning = 16
guild_discovery_grace_period_final_warning = 17
thread_created = 18
reply = 19
chat_input_command = 20
thread_starter_message = 21
guild_invite_reminder = 22
context_menu_command = 23
auto_moderation_action = 24
role_subscription_purchase = 25
interaction_premium_upsell = 26
stage_start = 27
stage_end = 28
stage_speaker = 29
stage_topic = 31
guild_application_premium_subscription = 32
guild_incident_alert_mode_enabled = 36
guild_incident_alert_mode_disabled = 37
guild_incident_report_raid = 38
guild_incident_report_false_alarm = 39
purchase_notification = 44
poll_result = 46
forward = 100
# ---------------------------------------------------------------------------
# Backup colour / permissions helpers
# ---------------------------------------------------------------------------
class BackupColor:
"""Minimal stand-in for discord.Color."""
__slots__ = ("value",)
def __init__(self, value: int = 0):
self.value = value
def __str__(self) -> str:
return f"#{self.value:06x}"
def __repr__(self) -> str:
return f"BackupColor(value={self.value})"
def __eq__(self, other):
if isinstance(other, (BackupColor, int)):
return self.value == (other.value if isinstance(other, BackupColor) else other)
return NotImplemented
@classmethod
def from_hex(cls, hex_str: str) -> "BackupColor":
"""Parse '#rrggbb' or '0x...' to BackupColor."""
hex_str = hex_str.lstrip("#")
try:
return cls(int(hex_str, 16))
except ValueError:
return cls(0)
class BackupPermissions:
"""Minimal stand-in for discord.Permissions."""
__slots__ = ("value",)
def __init__(self, value: int = 0):
self.value = value
@property
def view_channel(self) -> bool:
return bool(self.value & 0x400)
@property
def read_message_history(self) -> bool:
return bool(self.value & 0x10000)
def __repr__(self) -> str:
return f"BackupPermissions(value={self.value})"
class BackupPermissionOverwrite:
"""Minimal stand-in for discord.PermissionOverwrite.
Supports:
- .pair() -> (BackupPermissions(allow), BackupPermissions(deny))
- iteration: yields (perm_name, True|False|None) tuples
- setattr(ow, perm_name, value) for merging
"""
# Standard Discord permission flag names and their bit positions
VALID_NAMES = {
"create_instant_invite": 0, "kick_members": 1, "ban_members": 2,
"administrator": 3, "manage_channels": 4, "manage_guild": 5,
"add_reactions": 6, "view_audit_log": 7, "priority_speaker": 8,
"stream": 9, "view_channel": 10, "send_messages": 11,
"send_tts_messages": 12, "manage_messages": 13, "embed_links": 14,
"attach_files": 15, "read_message_history": 16, "mention_everyone": 17,
"use_external_emojis": 18, "view_guild_insights": 19, "connect": 20,
"speak": 21, "mute_members": 22, "deafen_members": 23,
"move_members": 24, "use_vad": 25, "change_nickname": 26,
"manage_nicknames": 27, "manage_roles": 28, "manage_webhooks": 29,
"manage_emojis_and_stickers": 30, "use_application_commands": 31,
"request_to_speak": 32, "manage_events": 33, "manage_threads": 34,
"create_public_threads": 35, "create_private_threads": 36,
"use_external_stickers": 37, "send_messages_in_threads": 38,
"use_embedded_activities": 39, "moderate_members": 40,
}
def __init__(self, allow: int = 0, deny: int = 0):
self._allow = allow
self._deny = deny
def pair(self):
"""Returns (Permissions(allow), Permissions(deny))."""
return BackupPermissions(self._allow), BackupPermissions(self._deny)
def __iter__(self):
"""Yields (perm_name, True|False|None) for each known permission."""
for name, bit in self.VALID_NAMES.items():
mask = 1 << bit
if self._allow & mask:
yield name, True
elif self._deny & mask:
yield name, False
else:
yield name, None
def __setattr__(self, name, value):
if name.startswith("_") or name not in self.VALID_NAMES:
super().__setattr__(name, value)
return
bit = self.VALID_NAMES[name]
mask = 1 << bit
# Clear both first
self._allow &= ~mask
self._deny &= ~mask
if value is True:
self._allow |= mask
elif value is False:
self._deny |= mask
# None = neutral, both cleared
class BackupOverwriteTarget:
"""Stand-in for the target (role) key in overwrites dict.
Satisfies: type(target).__name__ == 'Role' and target.id
"""
def __init__(self, role_id: int):
self.id = role_id
def __repr__(self):
return f"Role(id={self.id})"
# Allow `type(target).__name__` to return "Role"
BackupOverwriteTarget.__name__ = "Role"
BackupOverwriteTarget.__qualname__ = "Role"
# ---------------------------------------------------------------------------
# Backup discord.py model objects
# ---------------------------------------------------------------------------
class BackupAsset:
"""Minimal stand-in for discord.Asset. Points to a local file."""
__slots__ = ("_path", "url")
def __init__(self, local_path: Path | str | None):
self._path = Path(local_path) if local_path else None
self.url = str(local_path) if local_path else None
async def read(self) -> bytes:
if self._path and self._path.exists():
return self._path.read_bytes()
return b""
def is_animated(self) -> bool:
if self._path:
return self._path.suffix.lower() in (".gif",)
return False
def __bool__(self) -> bool:
return self._path is not None and self._path.exists()
class BackupRole:
"""Minimal stand-in for discord.Role."""
__slots__ = ("id", "name", "color", "position", "permissions", "hoist", "managed", "mentionable")
def __init__(self, data: dict):
if not isinstance(data, dict):
self.id = 0
self.name = "Unknown"
return
self.id = parse_snowflake(data["id"])
self.name = data["name"]
self.color = BackupColor(parse_snowflake(data.get("color", 0)) or 0)
self.position = data.get("position", 0)
self.permissions = BackupPermissions(parse_snowflake(data.get("permissions", 0)) or 0)
self.hoist = bool(data.get("hoist", False))
self.managed = False
self.mentionable = bool(data.get("mentionable", True))
@property
def mention(self) -> str:
return f"<@&{self.id}>"
@property
def created_at(self) -> datetime:
return datetime.now(timezone.utc)
def is_default(self) -> bool:
return self.name == "@everyone"
def __repr__(self) -> str:
return f"BackupRole(id={self.id}, name='{self.name}')"
def _parse_overwrites(raw_list: list | Any) -> dict:
"""Parse overwrites JSON list into {BackupOverwriteTarget: BackupPermissionOverwrite} dict."""
result = {}
if not isinstance(raw_list, list):
return result
for entry in raw_list:
if not isinstance(entry, dict):
continue
try:
target = BackupOverwriteTarget(parse_snowflake(entry["id"]))
ow = BackupPermissionOverwrite(allow=parse_snowflake(entry.get("allow", 0)) or 0,
deny=parse_snowflake(entry.get("deny", 0)) or 0)
result[target] = ow
except (KeyError, ValueError, TypeError):
continue
return result
class BackupCategory:
"""Minimal stand-in for discord.CategoryChannel."""
__slots__ = ("id", "name", "position", "type", "overwrites")
def __init__(self, data: dict):
try:
self.id = parse_snowflake(data["id"])
except (ValueError, TypeError):
self.id = 0 # 'uncategorized' sentinel
self.name = data["name"]
self.position = data.get("position", 0)
self.type = ChannelType.category
# Overwrites are now passed as a list of dicts from get_all_channels
raw_ow = data.get("overwrites", [])
self.overwrites = _parse_overwrites(raw_ow)
def __repr__(self) -> str:
return f"BackupCategory(id={self.id}, name='{self.name}')"
class BackupChannel:
"""Minimal stand-in for discord.TextChannel / ForumChannel / VoiceChannel."""
__slots__ = ("id", "name", "type", "position", "topic", "nsfw",
"category_id", "available_tags", "parent_id", "guild", "overwrites",
"bitrate", "slowmode_delay")
_TYPE_MAP = {
"text": ChannelType.text,
"voice": ChannelType.voice,
"news": ChannelType.news,
"forum": ChannelType.forum,
"thread": ChannelType.public_thread,
}
def __init__(self, data: dict, category_id: int | None = None, guild: "BackupGuild|None" = None):
self.id = parse_snowflake(data["id"])
self.name = data["name"]
try:
self.type = ChannelType(parse_snowflake(data.get("type", 0)) or 0)
except ValueError:
self.type = ChannelType.text
self.position = data.get("position", 0)
self.topic = data.get("topic")
self.nsfw = bool(data.get("nsfw", False))
self.bitrate = data.get("bitrate")
self.slowmode_delay = data.get("slowmode_delay")
cid = data.get("category_id")
self.category_id = parse_snowflake(cid) if cid else category_id
self.parent_id = self.category_id
self.guild = guild
# Overwrites are now passed as a list of dicts from get_all_channels
raw_ow = data.get("overwrites", [])
self.overwrites = _parse_overwrites(raw_ow)
# Forum tags are now structural
self.available_tags = []
raw_tags = data.get("available_tags", [])
for t_data in raw_tags:
self.available_tags.append(BackupTag(t_data))
@property
def mention(self) -> str:
return f"<#{self.id}>"
@property
def created_at(self) -> datetime:
return datetime.now(timezone.utc)
@property
def jump_url(self) -> str:
return f"https://discord.com/channels/{self.guild.id if self.guild else 0}/{self.id}"
def permissions_for(self, member):
"""Minimal mock for permissions_for. Returns full permissions for now."""
return BackupPermissions(0x7FFFFFFF)
def __repr__(self) -> str:
return f"BackupChannel(id={self.id}, name='{self.name}', type={self.type})"
class BackupMember:
"""Minimal stand-in for discord.Member / discord.User."""
__slots__ = ("id", "name", "display_name", "bot", "color",
"roles", "avatar", "guild_permissions", "discriminator",
"global_name", "created_at", "joined_at", "status", "activity", "system",
"_avatar_url")
def __init__(self, data: dict, role_objects: list | None = None,
backup_path: Path | None = None):
if not isinstance(data, dict):
# Fallback for unexpected data format
self.id = 0
self.name = "Unknown"
self.display_name = "Unknown"
self.global_name = "Unknown"
self.bot = False
self.system = False
self.discriminator = "0000"
self.color = BackupColor(0)
self.roles = sorted(role_objects or [], key=lambda r: r.position, reverse=True)
self.guild_permissions = BackupPermissions(0)
self.created_at = datetime.now(timezone.utc)
self.joined_at = datetime.now(timezone.utc)
self.status = type("Status", (), {"value": "offline"})()
self.activity = None
self._avatar_url = None
self.avatar = BackupAsset(None)
return
self.id = parse_snowflake(data["id"])
self.name = data.get("username", "Unknown")
self.display_name = data.get("display_name") or self.name
self.global_name = self.display_name
self.bot = False
self.system = False
self.discriminator = "0000"
self.color = BackupColor(0)
self.roles = sorted(role_objects or [], key=lambda r: r.position, reverse=True)
self.guild_permissions = BackupPermissions(0)
self.created_at = datetime.now(timezone.utc)
self.joined_at = datetime.now(timezone.utc)
self.status = type("Status", (), {"value": "offline"})()
self.activity = None
self._avatar_url = data.get("avatar_url")
avatar_rel = data.get("avatar_file")
if avatar_rel and backup_path:
self.avatar = BackupAsset(backup_path / avatar_rel)
else:
self.avatar = BackupAsset(None)
if self.avatar:
self.avatar.url = self._avatar_url
@property
def mention(self) -> str:
return f"<@{self.id}>"
@property
def top_role(self) -> "BackupRole|None":
return self.roles[0] if self.roles else None
@property
def display_avatar(self) -> BackupAsset:
"""Returns an asset with the CDN URL if available, otherwise the local file asset."""
if self._avatar_url:
asset = BackupAsset(None)
asset.url = self._avatar_url
return asset
return self.avatar
def __repr__(self) -> str:
return f"BackupMember(id={self.id}, name='{self.name}')"
class BackupAttachment:
"""Minimal stand-in for discord.Attachment."""
__slots__ = ("id", "filename", "size", "url", "proxy_url", "_backup_root", "local_hash", "content_type")
def __init__(self, data: dict, backup_root: Path | None = None, media_pool: dict | None = None):
if not isinstance(data, dict):
self.id = 0
self.filename = "unknown"
return
self.id = parse_snowflake(data["id"])
self.filename = data.get("filename", "unknown")
self.size = data.get("size", 0)
self.url = data.get("url", "")
self.proxy_url = self.url
self._backup_root = backup_root
self.local_hash = data.get("local_hash")
self.content_type = data.get("content_type")
# Resolve local path and ensure content_type via media pool if possible
if media_pool and self.local_hash in media_pool:
pool_entry = media_pool[self.local_hash]
self.url = pool_entry["local_path"]
if not self.content_type:
self.content_type = pool_entry.get("content_type")
elif self.local_hash:
# Fallback conjecture if pool didn't have it (e.g. ad-hoc load)
pass
async def read(self) -> bytes:
if self._backup_root:
full = self._backup_root / self.url
if full.exists():
return full.read_bytes()
return b""
async def save(self, path) -> None:
data = await self.read()
Path(path).write_bytes(data)
def __repr__(self) -> str:
return f"BackupAttachment(id={self.id}, filename='{self.filename}')"
class BackupEmoji:
"""Minimal stand-in for discord.Emoji."""
__slots__ = ("id", "name", "animated", "url", "_file_path")
def __init__(self, data: dict, media_dir: Path | None = None):
if not isinstance(data, dict):
self.id = 0
self.name = "Unknown"
return
self.id = parse_snowflake(data["id"])
self.name = data["name"]
self.animated = data.get("content_type") == "image/gif"
filename = data.get("filename", "")
self._file_path = media_dir / filename if media_dir and filename else None
# Use the local path if available, else original URL
self.url = str(self._file_path) if self._file_path else data.get("url", "")
async def read(self) -> bytes:
if self._file_path and self._file_path.exists():
return self._file_path.read_bytes()
return b""
def __repr__(self) -> str:
return f"BackupEmoji(id={self.id}, name='{self.name}')"
class BackupSticker:
"""Minimal stand-in for discord.GuildSticker."""
__slots__ = ("id", "name", "url", "format", "_backup_root", "_file_path", "local_hash")
def __init__(self, data: dict, backup_root: Path | None = None, media_pool: dict | None = None):
if not isinstance(data, dict):
self.id = 0
self.name = "Sticker"
self.local_hash = None
return
self.id = parse_snowflake(data.get("id") or data.get("sticker_id", 0)) or 0
self.name = data.get("name", "Sticker")
# Determine format
fmt = data.get("format_type", 1)
try:
self.format = StickerFormatType(fmt)
except ValueError:
self.format = StickerFormatType.png
self._backup_root = backup_root
# 1. Check if it's a CAS-based sticker (from message_stickers table)
self.local_hash = data.get("local_hash")
if self.local_hash and backup_root:
ext = ".png"
if self.format == StickerFormatType.lottie: ext = ".json"
elif self.format == StickerFormatType.apng: ext = ".png"
elif self.format == StickerFormatType.gif: ext = ".gif"
self._file_path = backup_root / "attachments" / f"{self.local_hash}{ext}"
# 2. Check if it's a server asset sticker (legacy or manual save)
elif data.get("filename") and backup_root:
self._file_path = backup_root / "server_assets" / data["filename"]
else:
self._file_path = None
self.url = str(self._file_path) if self._file_path and self._file_path.exists() else data.get("url", "")
async def read(self) -> bytes:
if self._file_path and self._file_path.exists():
return self._file_path.read_bytes()
return b""
def __repr__(self) -> str:
return f"BackupSticker(id={self.id}, name='{self.name}')"
class BackupPartialEmoji:
"""Minimal stand-in for discord.PartialEmoji."""
__slots__ = ("name", "id", "animated")
def __init__(self, name: str, id: int | None = None, animated: bool = False):
self.name = name
self.id = id
self.animated = animated
def __str__(self) -> str:
if self.id:
return f"<{'a' if self.animated else ''}:{self.name}:{self.id}>"
return self.name
class BackupReaction:
"""Minimal stand-in for discord.Reaction."""
__slots__ = ("emoji", "count")
def __init__(self, data: dict):
emoji_raw = data.get("emoji", "")
self.count = data.get("count", 0)
if ":" in emoji_raw and not emoji_raw.startswith("<"):
parts = emoji_raw.split(":", 1)
try:
self.emoji = BackupPartialEmoji(name=parts[0], id=parse_snowflake(parts[1]))
except (ValueError, IndexError):
self.emoji = BackupPartialEmoji(name=emoji_raw)
else:
self.emoji = BackupPartialEmoji(name=emoji_raw)
def is_custom_emoji(self) -> bool:
return self.emoji.id is not None
class BackupTag:
"""Minimal stand-in for discord.ForumTag."""
__slots__ = ("id", "name", "moderated", "emoji")
def __init__(self, data: dict):
self.id = parse_snowflake(data["id"])
self.name = data["name"]
self.moderated = bool(data.get("moderated", False))
emoji_id = data.get("emoji_id")
emoji_name = data.get("emoji_name")
self.emoji = BackupPartialEmoji(name=emoji_name, id=parse_snowflake(emoji_id)) if emoji_name else None
def __repr__(self) -> str:
return f"BackupTag(id={self.id}, name='{self.name}')"
class BackupThread:
"""Minimal stand-in for discord.Thread metadata attached to a starter message."""
__slots__ = ("id", "name", "message_count", "archived",
"auto_archive_duration", "locked", "parent_id", "applied_tags", "type")
def __init__(self, data: dict, parent_id: int | None = None):
if not isinstance(data, dict):
self.id = 0
self.name = ""
return
self.id = parse_snowflake(data["id"])
self.name = data.get("name", "")
try:
if data.get("type") is not None:
self.type = ChannelType(parse_snowflake(data.get("type", 11)) or 11)
else:
self.type = ChannelType.public_thread
except ValueError:
self.type = ChannelType.public_thread
self.message_count = data.get("message_count", 0)
self.archived = data.get("archived", False)
self.auto_archive_duration = data.get("auto_archive_duration", 1440)
self.locked = data.get("locked", False)
pid = data.get("parent_id")
self.parent_id = parse_snowflake(pid) if pid else parent_id
# Parse applied tags (JSON IDs)
self.applied_tags = []
raw_tags = data.get("applied_tags")
if raw_tags:
try:
tag_ids = json.loads(raw_tags) if isinstance(raw_tags, str) else raw_tags
self.applied_tags = [parse_snowflake(tid) for tid in tag_ids if parse_snowflake(tid)]
except Exception: pass
class BackupMessage:
"""Minimal stand-in for discord.Message."""
_TYPE_MAP = {
"Default": MessageType.default,
"Reply": MessageType.reply,
"ThreadStarter": MessageType.thread_starter_message,
"Forward": 100,
}
__slots__ = ("id", "type", "created_at", "pinned", "content", "author",
"attachments", "embeds", "stickers", "reactions",
"reference", "thread", "channel_id", "flags", "guild", "channel",
"mentions", "role_mentions", "channel_mentions", "mention_everyone",
"tts", "nonce", "webhook_id", "application_id", "activity",
"application", "interaction", "components", "jump_url", "message_snapshots")
def __init__(self, data: dict, *,
author: BackupMember | None = None,
guild: "BackupGuild | None" = None,
channel: Optional[Any] = None,
backup_root: Path | None = None,
media_pool: dict | None = None):
self.id = parse_snowflake(data["id"])
try:
if data.get("type") is not None:
self.type = MessageType(parse_snowflake(data.get("type", 0)) or 0)
else:
self.type = MessageType.default
except ValueError:
self.type = MessageType.default
self.pinned = bool(data.get("is_pinned", False))
self.content = data.get("content", "")
self.author = author
self.guild = guild
self.channel = channel
cid = data.get("channel_id")
self.channel_id = parse_snowflake(cid) if cid else (channel.id if channel else None)
# Mentions (resolved on the fly by clean_mentions via guild.get_member)
self.mentions = []
self.role_mentions = []
self.channel_mentions = []
self.mention_everyone = False
self.tts = False
self.nonce = None
self.webhook_id = None
self.application_id = None
self.activity = None
self.application = None
self.interaction = None
self.components = []
self.jump_url = f"https://discord.com/channels/{guild.id if guild else 0}/{self.channel_id}/{self.id}"
# Timestamp
ts = data.get("timestamp")
if ts:
try:
self.created_at = datetime.fromisoformat(ts).replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
self.created_at = datetime.now(timezone.utc)
else:
self.created_at = datetime.now(timezone.utc)
# Attachments
self.attachments = []
raw_atts = data.get("attachments", [])
if isinstance(raw_atts, str):
try:
raw_atts = json.loads(raw_atts)
except Exception:
raw_atts = []
for a in raw_atts:
if isinstance(a, dict):
self.attachments.append(BackupAttachment(a, backup_root=backup_root, media_pool=media_pool))
# Embeds
self.embeds = []
raw_embeds = data.get("embeds", [])
if isinstance(raw_embeds, str):
try:
raw_embeds = json.loads(raw_embeds)
except Exception:
raw_embeds = []
for e in raw_embeds:
if isinstance(e, dict):
self.embeds.append(BackupEmbed(e))
# Stickers
self.stickers = []
raw_stickers = data.get("stickers", [])
if isinstance(raw_stickers, str):
try:
raw_stickers = json.loads(raw_stickers)
except Exception:
raw_stickers = []
for s in raw_stickers:
if isinstance(s, dict):
self.stickers.append(BackupSticker(s, backup_root=backup_root, media_pool=media_pool))
# Reactions
self.reactions = []
raw_reactions = data.get("reactions", [])
if isinstance(raw_reactions, str):
try:
self.reactions = json.loads(raw_reactions)
except Exception:
self.reactions = []
elif isinstance(raw_reactions, list):
self.reactions = raw_reactions
# Reference (replies/forwards)
self.reference = None
if data.get("message_reference"):
self.reference = BackupMessageReference(
message_id=parse_snowflake(data["message_reference"]),
channel_id=self.channel_id
)
self.thread = None
self.flags = BackupMessageFlags(forwarded=self.type == MessageType.forward)
self.message_snapshots = []
def __repr__(self) -> str:
return f"BackupMessage(id={self.id}, author={self.author})"
class BackupMessageReference:
__slots__ = ("message_id", "channel_id")
def __init__(self, message_id: int | None = None, channel_id: int | None = None):
self.message_id = message_id
self.channel_id = channel_id
class BackupMessageFlags:
__slots__ = ("value", "forwarded")
def __init__(self, value: int = 0, forwarded: bool = False):
self.value = value
self.forwarded = forwarded
class BackupEmbed:
"""Minimal stand-in for discord.Embed."""
__slots__ = ("title", "description", "url", "color", "timestamp",
"thumbnail", "image", "author", "footer", "fields")
def __init__(self, data: dict):
self.title = data.get("title")
self.description = data.get("description")
self.url = data.get("url")
self.color = data.get("color")
self.timestamp = data.get("timestamp")
self.thumbnail = BackupEmbedThumbnail(data["thumbnail"]["url"]) if data.get("thumbnail") and "url" in data["thumbnail"] else None
self.image = BackupEmbedImage(data["image"]["url"]) if data.get("image") and "url" in data["image"] else None
author = data.get("author")
self.author = BackupEmbedAuthor(
name=author.get("name"),
url=author.get("url"),
icon_url=author.get("icon_url")
) if author else None
footer = data.get("footer")
self.footer = BackupEmbedFooter(
text=footer.get("text"),
icon_url=footer.get("icon_url")
) if footer else None
self.fields = [BackupEmbedField(f) for f in data.get("fields", [])]
def to_dict(self) -> dict:
result = {
"type": "rich" # Default for bot-sent embeds
}
if self.title: result["title"] = self.title
if self.description: result["description"] = self.description
if self.url: result["url"] = self.url
if self.color: result["color"] = self.color
if self.timestamp: result["timestamp"] = self.timestamp
if self.thumbnail: result["thumbnail"] = {"url": self.thumbnail.url}
if self.image: result["image"] = {"url": self.image.url}
if self.author:
result["author"] = {
"name": self.author.name,
"url": self.author.url,
"icon_url": self.author.icon_url
}
if self.footer:
result["footer"] = {
"text": self.footer.text,
"icon_url": self.footer.icon_url
}
if self.fields:
result["fields"] = [{"name": f.name, "value": f.value, "inline": f.inline} for f in self.fields]
return result
class BackupEmbedThumbnail:
__slots__ = ("url",)
def __init__(self, url: str): self.url = url
class BackupEmbedImage:
__slots__ = ("url",)
def __init__(self, url: str): self.url = url
class BackupEmbedAuthor:
__slots__ = ("name", "url", "icon_url")
def __init__(self, name: str | None = None, url: str | None = None, icon_url: str | None = None):
self.name = name
self.url = url
self.icon_url = icon_url
class BackupEmbedFooter:
__slots__ = ("text", "icon_url")
def __init__(self, text: str | None = None, icon_url: str | None = None):
self.text = text
self.icon_url = icon_url
class BackupEmbedField:
"""Minimal stand-in for embed fields."""
__slots__ = ("name", "value", "inline")
def __init__(self, data: dict):
self.name = data.get("name")
self.value = data.get("value")
self.inline = bool(data.get("inline", False))
class BackupGuild:
"""Minimal stand-in for discord.Guild."""
__slots__ = ("id", "name", "icon", "banner", "_reader")
def __init__(self, data: dict, backup_path: Path, reader: "BackupReader" = None):
self.id = parse_snowflake(data["id"])
self.name = data["name"]
self._reader = reader
icon_file = data.get("icon_file")
self.icon = BackupAsset(backup_path / icon_file) if icon_file else BackupAsset(None)
if self.icon:
self.icon.url = data.get("icon_url")
banner_file = data.get("banner_file")
self.banner = BackupAsset(backup_path / banner_file) if banner_file else BackupAsset(None)
if self.banner:
self.banner.url = data.get("banner_url")
@property
def active_threads(self) -> List[BackupThread]:
"""Returns all threads that are not archived."""
if self._reader:
return [t for t in self._reader._threads if not t.archived]
return []
@property
def roles(self) -> List[BackupRole]:
return self._reader._roles if self._reader else []
@property
def members(self) -> List[BackupMember]:
return self._reader._members if self._reader else []
@property
def channels(self) -> List[BackupChannel]:
return self._reader._channels if self._reader else []
@property
def categories(self) -> List[BackupCategory]:
return self._reader._categories if self._reader else []
@property
def emojis(self) -> List[BackupEmoji]:
return self._reader._emojis if self._reader else []
@property
def stickers(self) -> List[BackupSticker]:
return self._reader._stickers if self._reader else []
def get_member(self, user_id: int) -> "BackupMember | None":
if self._reader:
uid = parse_snowflake(user_id)
if uid in self._reader._member_map:
return self._reader._member_map[uid]
# Lazy load from DB
return self._reader._resolve_author(uid)
return None
def get_role(self, role_id: int) -> "BackupRole | None":
if self._reader:
return next((r for r in self._reader._roles if r.id == parse_snowflake(role_id)), None)
return None
def get_channel(self, channel_id: int) -> "BackupChannel | None":
if self._reader:
return next((c for c in self._reader._channels if c.id == parse_snowflake(channel_id)), None)
return None
def get_thread(self, thread_id: int) -> "BackupThread | None":
"""Mock discord.Guild.get_thread."""
if self._reader:
return next((t for t in self._reader._threads if t.id == parse_snowflake(thread_id)), None)
return None
async def fetch_channels(self) -> List[Union["BackupChannel", "BackupCategory"]]:
"""Async stub for discord.Guild.fetch_channels."""
if self._reader:
self._reader._ensure_structure_loaded()
return list(self._reader._channels) + list(self._reader._categories)
return []
async def fetch_active_threads(self) -> List["BackupThread"]:
"""Async stub for discord.Guild.active_threads helper (mirrors the property)."""
return self.active_threads
def __repr__(self) -> str:
return f"BackupGuild(id={self.id}, name='{self.name}')"
# ---------------------------------------------------------------------------
# Custom Forbidden exception for backup context
# ---------------------------------------------------------------------------
class BackupForbidden(Exception):
"""Raised when a requested resource doesn't exist in the backup."""
pass
# ---------------------------------------------------------------------------
# BackupReader — main provider class
# ---------------------------------------------------------------------------
class BackupReader:
"""Reads from local backup files instead of the Discord API.
Implements the same public interface as DiscordReader so that
migration scripts and UI code can use either provider transparently.
"""
# -- Provider constants (same interface as DiscordReader) --
MESSAGE_TYPE_DEFAULT = MessageType.default
MESSAGE_TYPE_REPLY = MessageType.reply
MESSAGE_TYPE_THREAD_STARTER = MessageType.thread_starter_message
MESSAGE_TYPE_FORWARD = MessageType.forward # Custom Reaper constant
MESSAGE_TYPE_CHAT_INPUT_COMMAND = MessageType.chat_input_command
MESSAGE_TYPE_CONTEXT_MENU_COMMAND = MessageType.context_menu_command
MESSAGE_TYPE_POLL_RESULT = MessageType.poll_result
MESSAGE_TYPE_AUTO_MODERATION_ACTION = MessageType.auto_moderation_action
Forbidden = BackupForbidden
CHANNEL_TYPE_TEXT = ChannelType.text
CHANNEL_TYPE_VOICE = ChannelType.voice
CHANNEL_TYPE_NEWS = ChannelType.news
CHANNEL_TYPE_FORUM = ChannelType.forum
@staticmethod
def find_item(iterable, **attrs):
"""Find first item in iterable matching all attrs."""
for item in iterable:
if all(getattr(item, k, None) == v for k, v in attrs.items()):
return item
return None
@staticmethod
def create_permission_overwrite():
"""Factory for a PermissionOverwrite mock."""
return BackupPermissionOverwrite()
def __init__(self, backup_path: str | Path):
self.backup_path = Path(backup_path)
self.guild: BackupGuild | None = None
self.db: Optional[BackupDatabase] = None
# Cache for performance
self._media_pool: Dict[str, Dict[str, Any]] = {}
# Lazy loading flags
self._roles_loaded = False
self._structure_loaded = False
self._assets_loaded = False
self._members_loaded = False
# Internal storage
self._categories: List[BackupCategory] = []
self._channels: List[BackupChannel] = []
self._threads: List[BackupThread] = []
self._thread_map: Dict[int, BackupThread] = {} # Starter Message ID -> Thread
self._roles: List[BackupRole] = []
self._emojis: List[BackupEmoji] = []
self._stickers: List[BackupSticker] = []
self._members: List[BackupMember] = []
self._member_map: Dict[int, BackupMember] = {}
# ── startup ──────────────────────────────────────────────────────────
async def start(self):
"""Initializes the backup path and the SQLite database."""
bp = self.backup_path
db_path = bp / "backup.db"
if db_path.exists():
self.db = BackupDatabase(db_path)
profile = self.db.get_guild_profile()
if profile:
self.guild = BackupGuild(profile, bp, reader=self)
logger.info(f"[Backup] Initialized database: {self.guild.name} ({self.guild.id})")
else:
logger.warning(f"[Backup] backup.db not found in {bp}")
self.guild = None
@property
def roles(self) -> List[BackupRole]:
if not self._roles_loaded and self.db:
logger.info(f"[Backup] Loading roles from DB...")
rows = self.db.get_all_roles()
self._roles = [BackupRole(r) for r in rows]
self._roles_loaded = True
return self._roles
@property
def categories(self) -> List[BackupCategory]:
self._ensure_structure_loaded()
return self._categories
@property
def threads(self) -> List[BackupThread]:
self._ensure_structure_loaded()
return self._threads
@property
def channels(self) -> List[BackupChannel]:
self._ensure_structure_loaded()
return self._channels
def _ensure_structure_loaded(self):
if self._structure_loaded or not self.db:
return
logger.info(f"[Backup] Loading server structure from DB...")
rows = self.db.get_all_channels()
for data in rows:
if data["type"] == 4: # Category
self._categories.append(BackupCategory(data))
else:
self._channels.append(BackupChannel(data, guild=self.guild))
# Load threads
thread_rows = self.db.get_all_threads()
for tdata in thread_rows:
thread = BackupThread(tdata)
self._threads.append(thread)
if thread.id:
self._thread_map[thread.id] = thread
# Resolve tag IDs to BackupTag objects using parent forum's available_tags
if thread.applied_tags and thread.parent_id:
parent = next((c for c in self._channels if c.id == thread.parent_id), None)
if parent and hasattr(parent, "available_tags"):
resolved = []
for tid in thread.applied_tags:
# tid is int because of BackupThread.__init__
tag = next((tag for tag in parent.available_tags if tag.id == tid), None)
if tag:
resolved.append(tag)
thread.applied_tags = resolved
self._structure_loaded = True
@property
def emojis(self) -> List[BackupEmoji]:
self._ensure_assets_loaded()
return self._emojis
@property
def stickers(self) -> List[BackupSticker]:
self._ensure_assets_loaded()
return self._stickers
def _ensure_assets_loaded(self):
if self._assets_loaded or not self.db:
return
logger.info(f"[Backup] Loading assets from DB...")
media_dir = self.backup_path / "server_assets"
emoji_rows = self.db.get_server_assets("emoji")
self._emojis = [BackupEmoji(e, media_dir) for e in emoji_rows if isinstance(e, dict)]
sticker_rows = self.db.get_server_assets("sticker")
self._stickers = [BackupSticker(s, self.backup_path) for s in sticker_rows if isinstance(s, dict)]
self._assets_loaded = True
@property
def members(self) -> List[BackupMember]:
self._ensure_members_loaded()
return self._members
def _ensure_members_loaded(self):
if self._members_loaded or not self.db:
return
logger.info(f"[Backup] Loading members from DB...")
rows = self.db.get_all_users()
bp = self.backup_path
for u in rows:
if u.get("roles") and isinstance(u["roles"], str):
try:
u["roles"] = json.loads(u["roles"])
except Exception:
u["roles"] = []
user_role_ids = set()
for rid in (u.get("roles") or []):
try:
rid_parsed = parse_snowflake(rid)
if rid_parsed:
user_role_ids.add(rid_parsed)
except (ValueError, TypeError):
continue
role_objs = [r for r in self.roles if r.id in user_role_ids]
member = BackupMember(u, role_objects=role_objs, backup_path=bp)
self._members.append(member)
self._member_map[member.id] = member
self._members_loaded = True
# ── validation ───────────────────────────────────────────────────────
async def validate(self) -> Dict[str, Any]:
"""Validates backup database integrity."""
results = {
"token": False,
"server": False,
"bot_name": None,
"server_name": None,
"intents": {
"message_content": True,
"members": True
},
"permissions": {
"view_channel": True,
"read_messages": True,
"read_message_history": True
},
"error": None
}
bp = self.backup_path
db_path = bp / "backup.db"
if not bp.exists():
results["error"] = f"Backup folder not found: {bp}"
return results
if not db_path.exists():
results["error"] = f"backup.db not found in {bp}"
return results
try:
# Use sub-connection to validate
from src.core.backup_database import BackupDatabase
db = BackupDatabase(db_path)
profile = db.get_guild_profile()
if profile:
results["token"] = True
results["server"] = True
results["bot_name"] = "LOCAL BACKUP"
results["server_name"] = profile.get("name", "Unknown")
else:
results["error"] = "Backup profile (guild_profile table) is empty."
except Exception as e:
results["error"] = f"Database error: {str(e)}"
return results
# ── server metadata ──────────────────────────────────────────────────
async def get_server_metadata(self) -> Dict[str, Any]:
if not self.guild:
return {}
return {
"name": self.guild.name,
"id": str(self.guild.id),
"icon_url": str(self.guild.icon.url) if self.guild.icon else None,
"banner_url": str(self.guild.banner.url) if self.guild.banner else None,
}
async def download_asset(self, asset: BackupAsset) -> bytes:
return await asset.read()
# ── categories & channels ────────────────────────────────────────────
async def get_categories(self) -> List[BackupCategory]:
return list(self.categories)
async def get_channels(self, category_id: int | None = None) -> List[BackupChannel]:
channels = [c for c in self.channels if c.type != ChannelType.category]
if category_id is not None:
channels = [c for c in channels if c.category_id == category_id]
return channels
async def fetch_channels(self) -> List[Union[BackupChannel, BackupCategory]]:
"""Uniform interface for all channels (including categories)."""
return list(self.channels) + list(self.categories)
async def get_active_threads(self) -> List[BackupThread]:
"""Uniform interface for active threads."""
return [t for t in self.threads if not t.archived]
async def get_backed_up_channel_ids(self) -> List[int]:
"""Returns a list of channel IDs that have messages in the database."""
if not self.db: return []
return self.db.get_backed_up_channel_ids()
async def get_channel(self, channel_id: int) -> BackupChannel | BackupThread | None:
for c in self.channels:
if c.id == channel_id:
return c
for t in self.threads:
if t.id == channel_id:
return t
return None
# ── roles, emojis, stickers, members ─────────────────────────────────
async def get_roles(self) -> List[BackupRole]:
return [r for r in self.roles if not r.is_default()]
async def get_emojis(self) -> List[BackupEmoji]:
return list(self.emojis)
async def get_stickers(self) -> List[BackupSticker]:
return list(self.stickers)
async def get_members(self) -> List[BackupMember]:
return list(self.members)
# ── messages ─────────────────────────────────────────────────────────
def _ensure_media_pool_loaded(self):
if not self._media_pool and self.db:
self._media_pool = self.db.get_all_media()
def _resolve_author(self, user_id: int) -> BackupMember:
"""Returns BackupMember for a userID, creating a stub if missing."""
if user_id in self._member_map:
return self._member_map[user_id]
# Try to fetch from DB
user_data = self.db.get_user(str(user_id)) if self.db else None
if user_data:
user_role_ids = {parse_snowflake(rid) for rid in (user_data.get("roles") or []) if parse_snowflake(rid)}
role_objs = [r for r in self.roles if r.id in user_role_ids]
member = BackupMember(user_data, role_objects=role_objs, backup_path=self.backup_path)
self._members.append(member)
self._member_map[user_id] = member
return member
# Stub
stub = BackupMember({
"id": str(user_id),
"username": f"User#{str(user_id)[-4:]}",
})
self._member_map[user_id] = stub
return stub
def _hydrate_message(self, msg_data: dict) -> BackupMessage:
user_id = parse_snowflake(msg_data.get("author_id", 0)) or 0
author = self._resolve_author(user_id)
# Check for custom author profile (Webhooks / Masquerade)
over_name = msg_data.get("custom_display_name")
over_avatar = msg_data.get("custom_avatar_url")
if over_name:
# Create an ephemeral author object for this message
author = BackupMember({
"id": str(user_id),
"username": over_name,
"display_name": over_name,
"avatar_url": over_avatar
})
self._ensure_media_pool_loaded()
channel_id = parse_snowflake(msg_data["channel_id"])
channel = next((c for c in self.channels if c.id == channel_id), None)
bm = BackupMessage(
msg_data,
author=author,
guild=self.guild,
channel=channel,
backup_root=self.backup_path,
media_pool=self._media_pool
)
# Link thread if this is a starter message
if bm.id in self._thread_map:
bm.thread = self._thread_map[bm.id]
return bm
async def get_message(self, channel_id: int, message_id: int) -> BackupMessage | None:
"""Fetch a specific message from SQLite."""
if not self.db: return None
data = self.db.get_message_with_relations(message_id)
if data:
return self._hydrate_message(data)
return None
async def get_first_message(self, channel_id: int) -> BackupMessage | None:
"""Fetch the first message in a channel from SQLite."""
if not self.db: return None
msgs = self.db.get_messages_paged(str(channel_id), limit=1)
if msgs:
return self._hydrate_message(msgs[0])
return None
async def fetch_message_history(
self,
channel_id: int,
limit: int = None,
after_id: int = None,
inclusive: bool = False
) -> AsyncGenerator["BackupMessage", None]:
"""Yields BackupMessages from SQLite, respecting after_id and limit."""
if not self.db: return
offset = 0
batch_size = 100
count = 0
while True:
actual_limit = batch_size
if limit:
rem = limit - count
if rem <= 0: break
actual_limit = min(batch_size, rem)
msgs = self.db.get_messages_paged(
str(channel_id),
limit=actual_limit,
offset=offset,
after_id=str(after_id) if after_id else None
)
if not msgs:
break
for m in msgs:
yield self._hydrate_message(m)
count += 1
offset += len(msgs)
if len(msgs) < batch_size:
break
async def fetch_global_message_history(
self,
limit: int = None,
after_id: int = None
) -> AsyncGenerator["BackupMessage", None]:
"""Yields BackupMessages globally from SQLite across all channels, natively ordered by timestamp/ID."""
if not self.db: return
offset = 0
batch_size = 100
count = 0
while True:
actual_limit = batch_size
if limit:
rem = limit - count
if rem <= 0: break
actual_limit = min(batch_size, rem)
msgs = self.db.get_global_messages_paged(
limit=actual_limit,
offset=offset,
after_id=str(after_id) if after_id else None
)
if not msgs:
break
for m in msgs:
yield self._hydrate_message(m)
count += 1
offset += len(msgs)
if len(msgs) < batch_size:
break
# ── download helpers ─────────────────────────────────────────────────
async def download_emoji(self, emoji: BackupEmoji) -> bytes:
return await emoji.read()
async def download_sticker(self, sticker: BackupSticker) -> bytes:
return await sticker.read()
async def download_attachment(self, attachment: BackupAttachment) -> bytes:
return await attachment.read()
# ── lifecycle ────────────────────────────────────────────────────────
async def close(self):
"""No-op for backup reader (no connections to close)."""
pass