""" Wormhole — Advanced Cross-Server Relay Cog for Red-DiscordBot ============================================================== Features -------- • Named wormhole networks with full per-network config • Webhook relay with custom avatar/name modes (user, server, both, custom) • Message-edit, message-delete, reply, reaction, thread & sticker sync • Staff system — owner + staff per network with granular perms • Moderation — user ban/mute, server ban, word filter, regex filter • Rate limiting (token-bucket per user per network) • Per-network logging channel • Statistics dashboard (messages relayed, top users, top servers) • Server allowlist / blocklist per network • NSFW gating, freeze/pause, silent mode • Custom network icon, description, colour • Nickname overrides per server • Transfer network ownership • Full help embeds """ from __future__ import annotations import asyncio import logging import time from collections import defaultdict from copy import deepcopy from datetime import datetime, timezone from typing import Dict, List, Optional, Set import discord from redbot.core import Config, checks, commands from redbot.core.bot import Red from .utils import ( COLOUR_INFO, COLOUR_NEUTRAL, COLOUR_OK, CooldownBucket, check_filters, err_embed, human_timedelta, info_embed, ok_embed, truncate, warn_embed, ) log = logging.getLogger("red.evecogs.wormhole") # ── Default config structures ────────────────────────────────────────────── _DEFAULT_NETWORK = { # ownership / staff "owner_id": 0, "staff_ids": [], # channels "channels": [], # list of channel IDs # identity "use_webhooks": True, "image_mode": "user", # user | server | custom "name_mode": "both", # user | server | both | custom "custom_icon": None, # URL "custom_name": None, # fallback name template "colour": None, # hex int for embed accent "description": "", # moderation "banned_users": [], # user IDs "banned_servers": [], # guild IDs "muted_users": [], # user IDs "muted_servers": [], # guild IDs "word_filters": [], "regex_filters": [], "allowlist_servers": [], # if non-empty only these guilds may join # features "sync_edits": True, "sync_deletes": True, "sync_reactions": True, "sync_replies": True, "sync_threads": False, "sync_stickers": True, "forward_embeds": True, "nsfw_gate": True, # block NSFW channel content relay "silent": False, # suppress join/leave announcements "frozen": False, # pause all relay # rate limit "rate_limit_rate": 5, # messages "rate_limit_per": 10.0, # seconds # logging "log_channel": None, # server nicknames {guild_id_str: "nickname"} "server_nicknames": {}, # stats "total_messages": 0, "created_at": None, # ISO timestamp } _DEFAULT_GLOBAL = { "networks": {}, # {network_name: _DEFAULT_NETWORK} "max_networks_per_user": 10, } # message map: stores {original_msg_id: {channel_id: relayed_msg_id}} # Kept in memory only (not persisted — too noisy). Evicts after 2 000 entries. _MAP_LIMIT = 2_000 class _MessageMap: """In-memory bidirectional message-ID mapping per network.""" def __init__(self): # network -> original_msg_id -> {channel_id: relayed_msg_id} self.forward: Dict[str, Dict[int, Dict[int, int]]] = defaultdict(dict) # network -> relayed_msg_id -> original_msg_id self.reverse: Dict[str, Dict[int, int]] = defaultdict(dict) self._order: Dict[str, list] = defaultdict(list) def add(self, network: str, original_id: int, mapping: Dict[int, int]): self.forward[network][original_id] = mapping for ch_id, msg_id in mapping.items(): self.reverse[network][msg_id] = original_id self._order[network].append(original_id) # Evict while len(self._order[network]) > _MAP_LIMIT: old = self._order[network].pop(0) old_map = self.forward[network].pop(old, {}) for _ch, _mid in old_map.items(): self.reverse[network].pop(_mid, None) def get_relayed(self, network: str, original_id: int) -> Dict[int, int]: return self.forward.get(network, {}).get(original_id, {}) def get_original(self, network: str, relayed_id: int) -> Optional[int]: return self.reverse.get(network, {}).get(relayed_id) def get_all_relayed_ids(self, network: str, original_id: int) -> List[int]: return list(self.forward.get(network, {}).get(original_id, {}).values()) class Wormhole(commands.Cog): """Advanced cross-server relay with networks, moderation & full sync.""" __version__ = "3.0.0" def __init__(self, bot: Red): self.bot = bot self.config = Config.get_conf(self, identifier=7730187301, force_registration=True) self.config.register_global(**_DEFAULT_GLOBAL) self.webhook_cache: Dict[int, discord.Webhook] = {} self.cooldowns: Dict[str, CooldownBucket] = {} # per network self.msg_map = _MessageMap() self._ready = asyncio.Event() self._startup_task = self.bot.loop.create_task(self._init()) async def _init(self): await self.bot.wait_until_ready() # Warm cooldown buckets networks = await self.config.networks() for name, data in networks.items(): self.cooldowns[name] = CooldownBucket( data.get("rate_limit_rate", 5), data.get("rate_limit_per", 10.0), ) self._ready.set() log.info("Wormhole cog ready — %d networks loaded.", len(networks)) async def cog_unload(self): self._startup_task.cancel() # Clean up webhooks we created? No — leave them for next load. # ── Helpers ───────────────────────────────────────────────────────────── async def _get_network(self, name: str) -> Optional[dict]: networks = await self.config.networks() return networks.get(name) async def _save_network(self, name: str, data: dict): async with self.config.networks() as networks: networks[name] = data async def _network_for_channel(self, channel_id: int) -> Optional[str]: """Return the network name a channel belongs to, or None.""" networks = await self.config.networks() for name, data in networks.items(): if channel_id in data.get("channels", []): return name return None async def _get_webhook(self, channel: discord.TextChannel) -> discord.Webhook: if channel.id in self.webhook_cache: # Validate it's still alive try: return self.webhook_cache[channel.id] except Exception: pass try: webhooks = await channel.webhooks() for wh in webhooks: if wh.user == self.bot.user and wh.name == "Wormhole Relay": self.webhook_cache[channel.id] = wh return wh wh = await channel.create_webhook(name="Wormhole Relay") self.webhook_cache[channel.id] = wh return wh except discord.Forbidden: raise commands.UserFeedbackCheckFailure( "I don't have permission to manage webhooks in that channel." ) def _resolve_avatar(self, message: discord.Message, mode: str, custom_icon: Optional[str]) -> str: if mode == "server": icon = message.guild.icon return icon.url if icon else (message.author.display_avatar.url) if mode == "custom" and custom_icon: return custom_icon return message.author.display_avatar.url def _resolve_name( self, message: discord.Message, mode: str, custom_name: Optional[str], server_nick: Optional[str] = None, ) -> str: guild_label = server_nick or message.guild.name user_label = message.author.display_name if mode == "server": return guild_label if mode == "both": return f"{guild_label} • {user_label}" if mode == "custom" and custom_name: return custom_name.replace("{user}", user_label).replace("{server}", guild_label) return user_label # default "user" async def _is_staff(self, network_data: dict, user_id: int) -> bool: return user_id == network_data["owner_id"] or user_id in network_data.get("staff_ids", []) async def _is_owner(self, network_data: dict, user_id: int) -> bool: return user_id == network_data["owner_id"] async def _log_event(self, network_data: dict, embed: discord.Embed): ch_id = network_data.get("log_channel") if not ch_id: return ch = self.bot.get_channel(ch_id) if ch: try: await ch.send(embed=embed) except Exception: pass async def _relay_status(self, network_name: str, network_data: dict, source_channel: discord.Channel, text: str): """Send a status/announcement embed to every channel in the network except source.""" if network_data.get("silent"): return em = info_embed(text, title=f"🌀 {network_name}") for ch_id in network_data["channels"]: if ch_id == source_channel.id: continue ch = self.bot.get_channel(ch_id) if ch: try: await ch.send(embed=em) except Exception: pass # ── Main group ────────────────────────────────────────────────────────── @commands.group(name="wh", aliases=["wormhole"], invoke_without_command=True) async def wh(self, ctx: commands.Context): """🌀 Wormhole — advanced cross-server relay. Use `[p]wh help` for the full command list. """ await ctx.send_help(ctx.command) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # NETWORK MANAGEMENT # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @wh.command(name="create") async def wh_create(self, ctx: commands.Context, name: str, *, description: str = ""): """Create a new wormhole network. **name** must be alphanumeric + hyphens, max 32 chars. """ name = name.lower().strip() if not name.replace("-", "").replace("_", "").isalnum() or len(name) > 32: return await ctx.send(embed=err_embed("Network name must be alphanumeric (hyphens/underscores OK), max 32 chars.")) networks = await self.config.networks() if name in networks: return await ctx.send(embed=err_embed(f"Network **{name}** already exists.")) max_nets = await self.config.max_networks_per_user() owned = sum(1 for n in networks.values() if n["owner_id"] == ctx.author.id) if owned >= max_nets: return await ctx.send(embed=err_embed(f"You already own {owned}/{max_nets} networks.")) data = deepcopy(_DEFAULT_NETWORK) data["owner_id"] = ctx.author.id data["description"] = description data["created_at"] = datetime.now(timezone.utc).isoformat() await self._save_network(name, data) self.cooldowns[name] = CooldownBucket(data["rate_limit_rate"], data["rate_limit_per"]) em = ok_embed( f"Network **{name}** created!\n\n" f"• Add channels: `{ctx.clean_prefix}wh open {name}`\n" f"• Customise: `{ctx.clean_prefix}wh set {name}