diff --git a/wormhole/wormhole.py.old b/wormhole/wormhole.py.old new file mode 100644 index 0000000..8f7ff11 --- /dev/null +++ b/wormhole/wormhole.py.old @@ -0,0 +1,4787 @@ +""" +Wormhole v3.4.0 — The Ultimate Cross-Server Relay Cog for Red-DiscordBot +========================================================================= + +Phase 1: Named networks, webhook relay, edit/delete/reply/reaction/sticker sync, + staff system, moderation, filters, rate limiting, logging, stats +Phase 2: Embed/compact relay modes, auto-moderation, invites, announcements, + portals, welcome messages, mention control, user profiles, backup/restore, + blackout scheduling, global blocklist +Phase 3: DM relay (bidirectional), pin sync, starboard, audit log, anti-raid, + attachment filters, reputation/karma, MOTD/rules, relay delay, vanity + invites, keyword highlights/notifications, network roles, per-channel + overrides, scheduled messages, purge, typing indicators, network + discovery, message search, slowmode +Phase 4: Hardened command filtering, anonymous mode, personal ignore lists, + one-way mirror channels, network-wide polls, AFK system, ephemeral + messages, auto-responses, media-only mode, network analytics, + relay health monitor, message bookmarks, user colours, quiet hours, + network bridging +Phase 5: Hybrid commands (slash + prefix), context menu actions, granular + mention policy (per-network + per-server + per-user), rules/ToS + acceptance gate, mod edit/delete across network, user report system, + privacy & security hardening +Phase 5.1: Relay engine hardening — replaced heuristic command filter with + Red's internal bot.get_context() (only real commands filtered), + top-level try/except around entire relay chain, safe AFK/auto-response + calls, blackout loop no longer overrides manual freeze +Phase 5.2: Fix empty-prefix bug — get_context() with an empty-string prefix + matched every message, silently blocking all relay traffic. Now + requires ctx.prefix to be non-empty before filtering. Re-added + empty-prefix warning to wh debug. +Phase 5.3: Enhanced reply buttons — relayed replies now include a + "Jump to replied message" URL button that links to the local + copy of the referenced message (if available in the target + server) or falls back to the original message URL. +Phase 5.4: Foreign emoji relay — custom emojis unknown to the bot are + replaced with :name: in text and their image is shown as an + embed thumbnail (~80×80 px) via attachment:// so they stay + close to real emoji size. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import io +import json +import logging +import random +import re +import time +from collections import defaultdict +from copy import deepcopy +from datetime import datetime, timedelta, timezone +from typing import Dict, List, Optional, Set, Tuple + +import discord +from discord import app_commands +from redbot.core import Config, checks, commands +from redbot.core.bot import Red + +from .utils import ( + BLOCKED_EXTENSIONS, + COLOUR_ANNOUNCE, + COLOUR_DM, + COLOUR_INFO, + COLOUR_NEUTRAL, + COLOUR_OK, + COLOUR_STAR, + CooldownBucket, + DuplicateDetector, + RaidDetector, + announce_embed, + apply_mention_policy, + build_dm_incoming_embed, + build_dm_relay_embed, + build_portal_embed, + build_relay_embed, + build_star_embed, + check_attachment_filters, + check_automod, + check_filters, + compact_format, + dm_embed, + err_embed, + format_audit_entry, + generate_invite_code, + human_timedelta, + info_embed, + ok_embed, + sanitise_mentions, + star_embed, + truncate, + warn_embed, +) + +log = logging.getLogger("red.evecogs.wormhole") + +# ── Default config structures ────────────────────────────────────────────── + +_DEFAULT_NETWORK = { + # ownership / staff + "owner_id": 0, + "staff_ids": [], + # channels + "channels": [], + # identity + "use_webhooks": True, + "relay_mode": "webhook", # webhook | embed | compact + "image_mode": "user", # user | server | custom + "name_mode": "both", # user | server | both | custom + "custom_icon": None, + "custom_name": None, + "colour": None, + "description": "", + # moderation + "banned_users": [], + "banned_servers": [], + "muted_users": [], + "muted_servers": [], + "word_filters": [], + "regex_filters": [], + "allowlist_servers": [], + # features + "sync_edits": True, + "sync_deletes": True, + "sync_reactions": True, + "sync_replies": True, + "sync_threads": False, + "sync_stickers": True, + "sync_pins": False, + "forward_embeds": True, + "nsfw_gate": True, + "silent": False, + "frozen": False, + # rate limit + "rate_limit_rate": 5, + "rate_limit_per": 10.0, + # logging + "log_channel": None, + # server nicknames + "server_nicknames": {}, + # stats + "total_messages": 0, + "created_at": None, + # ── Phase 2 ── + "automod": { + "enabled": False, + "anti_spam": False, + "anti_mention_spam": False, + "anti_caps": False, + "anti_invite": False, + "anti_link": False, + "anti_zalgo": False, + "anti_spoiler": False, + "anti_emote_spam": False, + "anti_newline_spam": False, + "anti_raid": False, + "max_mentions": 5, + "caps_threshold": 0.7, + "spam_window": 30.0, + "spam_threshold": 3, + "max_emotes": 10, + "max_newlines": 15, + "raid_window": 60.0, + "raid_threshold": 10, + }, + "invites": {}, + "vanity_invite": None, # custom word invite + "portal_messages": {}, + "welcome_message": "", + "mention_control": { + "strip_everyone": True, + "strip_role_mentions": False, + "strip_user_mentions": False, + }, + "user_profiles": {}, + "blackout_schedules": [], + # ── Phase 3 ── + # DM relay + "dm_enabled": False, + "dm_subscribers": [], # user IDs + "dm_relay_mode": "embed", # embed | compact | plain + # Starboard + "starboard_enabled": False, + "starboard_channel": None, + "starboard_threshold": 3, + "starred_messages": {}, # {original_msg_id: {stars: int, board_msg_id: int}} + # Audit log + "audit_log": [], # list of audit entries (capped at 500) + # Attachment filters + "blocked_extensions": [], + "max_filesize": None, # bytes + # Reputation / Karma + "karma_enabled": False, + "karma_emoji": "👍", + "karma_scores": {}, # {user_id_str: int} + # MOTD / Rules + "motd": "", + "rules": "", + # Relay delay (seconds, 0 = instant) + "relay_delay": 0, + # Keyword highlights (DM notifications) + "highlights": {}, # {user_id_str: [keywords]} + # Network roles + "roles": {}, # {role_name: {perms: [...], members: [user_ids]}} + # Per-channel overrides + "channel_overrides": {}, # {channel_id_str: {key: value}} + # Scheduled messages + "scheduled_messages": [], # [{content, send_at_iso, author_id}] + # Slowmode (network-level, seconds between messages per user) + "slowmode": 0, + # Network discovery + "public": False, # show in wh discover + "tags": [], + # Typing indicator relay + "sync_typing": False, + # ── Phase 4 ── + # Anonymous mode + "anonymous": False, + "anon_salt": "", # random salt per-network for hashing + # One-way mirror channels (receive-only, messages FROM these channels are NOT relayed) + "mirror_channels": [], # channel IDs that are receive-only + # Ephemeral messages (auto-delete relayed messages after N seconds, 0=disabled) + "ephemeral_delay": 0, + # Auto-responses (staff-configured pattern → reply pairs) + "auto_responses": {}, # {pattern_str: {reply: str, regex: bool, cooldown: int, last_used: float}} + # Media-only mode (only relay messages that have attachments/embeds) + "media_only": False, + # Network analytics (rolling counters) + "analytics": { + "hourly": {}, # {"YYYY-MM-DD-HH": count} + "top_users": {}, # {user_id_str: count} (rolling — pruned monthly) + }, + # Relay health + "last_health_check": None, + "unhealthy_channels": [], + # Polls + "active_polls": {}, # {poll_id: {question, options, votes, author, created, expires, msg_map}} + # AFK system + "afk_users": {}, # {user_id_str: {reason, since_iso}} + # Personal ignore list (per-user, not relayed to them in DM) + "user_ignores": {}, # {user_id_str: [ignored_user_ids]} + # User vanity colours + "user_colours": {}, # {user_id_str: int (hex colour)} + # Quiet hours (per-user DM mute windows) + "quiet_hours": {}, # {user_id_str: {start_hour: int, end_hour: int, tz_offset: int}} + # Network bridging (one-way feeds from other networks) + "bridge_from": [], # network names whose messages also appear here + "bridge_to": [], # network names that receive our messages + # ── Phase 5 ── + # Granular mention policy + "mention_policy": { + "allow_user_mentions": False, # strip @user pings from relayed messages + "allow_role_mentions": False, # strip @role pings + "allow_everyone": False, # strip @everyone + "allow_here": False, # strip @here + }, + "server_mention_overrides": {}, # {guild_id_str: {same keys as mention_policy}} + "mention_exempt_users": [], # user IDs allowed to bypass mention policy + "mention_optout_users": [], # user IDs who opted out of being @pinged via relay + # Rules / Terms of Service acceptance gate + "rules_required": False, # must users accept before talking? + "rules_text": "", # the legal ToS text + "rules_accepted": {}, # {user_id_str: accepted_at_iso} + # Report system + "reports": [], # [{id, reporter_id, message_content_hash, reason, channel_id, guild_id, author_id, timestamp, resolved, resolved_by}] + "report_counter": 0, # auto-increment +} + +_DEFAULT_GLOBAL = { + "networks": {}, + "max_networks_per_user": 10, + "global_banned_users": [], + "global_banned_servers": [], + # Bookmarks (global per-user) + "bookmarks": {}, # {user_id_str: [{content, author, server, channel, timestamp}]} +} + +_MAP_LIMIT = 2_000 + + +def _reply_jump_view(url: str) -> discord.ui.View: + """Build a View with a single URL button that jumps to the replied message.""" + view = discord.ui.View() + view.add_item(discord.ui.Button( + style=discord.ButtonStyle.link, + label="↩ Jump to replied message", + url=url, + )) + return view + +_CUSTOM_EMOJI_RE = re.compile(r'<(a?):(\w+):(\d+)>') +_EMOJI_IMAGE_LIMIT = 5 # max emoji thumbnails per message + + +async def _resolve_foreign_emojis( + bot, content: str +) -> tuple[str, list[tuple[str, bytes]]]: + """Replace unknown custom emojis with :name: and fetch small images. + + Returns (new_content, [(filename, image_bytes), ...]). + Images are shown as embed thumbnails (~80×80 px) rather than full-size + file attachments. + """ + unknown: list[tuple[str, int, str]] = [] # (name, id, ext) + + def _check(m: re.Match) -> str: + animated, name, eid = m.group(1), m.group(2), int(m.group(3)) + if bot.get_emoji(eid): + return m.group(0) # known emoji — keep original syntax + ext = "gif" if animated else "png" + unknown.append((name, eid, ext)) + return f":{name}:" + + new_content = _CUSTOM_EMOJI_RE.sub(_check, content) + + emoji_data: list[tuple[str, bytes]] = [] + if unknown: + import aiohttp + async with aiohttp.ClientSession() as session: + for name, eid, ext in unknown[:_EMOJI_IMAGE_LIMIT]: + try: + url = f"https://cdn.discordapp.com/emojis/{eid}.{ext}?size=48" + async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: + if resp.status == 200: + emoji_data.append((f"{name}.{ext}", await resp.read())) + except Exception: + pass + + return new_content, emoji_data + + +def _emoji_embeds_and_files( + emoji_data: list[tuple[str, bytes]], +) -> tuple[list[discord.Embed], list[discord.File]]: + """Build embed-thumbnail pairs for foreign emoji images. + + Each unknown emoji gets a tiny embed whose thumbnail references the + attached file via ``attachment://``. Thumbnails render at ~80×80 px — + much closer to real emoji size than full-size file attachments. + """ + embeds: list[discord.Embed] = [] + files: list[discord.File] = [] + for fname, fdata in emoji_data: + f = discord.File(io.BytesIO(fdata), filename=fname) + em = discord.Embed() + em.set_thumbnail(url=f"attachment://{fname}") + files.append(f) + embeds.append(em) + return embeds, files + + +_AUDIT_LIMIT = 500 + + +class _MessageMap: + def __init__(self): + self.forward: Dict[str, Dict[int, Dict[int, int]]] = defaultdict(dict) + 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) + 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]: + # Try forward map first (original → relayed copies) + result = self.forward.get(network, {}).get(original_id, {}) + if result: + return result + # If not found, check if it's a relayed ID and resolve to original + orig = self.reverse.get(network, {}).get(original_id) + if orig: + return self.forward.get(network, {}).get(orig, {}) + return {} + + 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 _ReportModal(discord.ui.Modal, title="Report Message"): + """Modal for reporting a message through the context menu.""" + reason = discord.ui.TextInput( + label="Reason for report", + style=discord.TextStyle.paragraph, + placeholder="Describe why you're reporting this message...", + max_length=500, + required=True, + ) + + def __init__(self, cog: "Wormhole", net_name: str, message: discord.Message): + super().__init__() + self.cog = cog + self.net_name = net_name + self.target = message + + async def on_submit(self, interaction: discord.Interaction): + nd = await self.cog._net(self.net_name) + if not nd: + return await interaction.response.send_message("Network not found.", ephemeral=True) + + content_hash = hashlib.sha256((self.target.content or "").encode()).hexdigest()[:16] + async with self.cog.config.networks() as n: + counter = n[self.net_name].get("report_counter", 0) + 1 + n[self.net_name]["report_counter"] = counter + report = { + "id": counter, + "reporter_id": interaction.user.id, + "author_id": self.target.author.id, + "author_name": str(self.target.author), + "content_preview": truncate(self.target.content or "[no text]", 200), + "content_hash": content_hash, + "reason": str(self.reason)[:500], + "channel_id": self.target.channel.id, + "guild_id": self.target.guild.id if self.target.guild else 0, + "message_id": self.target.id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "resolved": False, + "resolved_by": None, + } + n[self.net_name].setdefault("reports", []).append(report) + if len(n[self.net_name]["reports"]) > 200: + n[self.net_name]["reports"] = n[self.net_name]["reports"][-200:] + + self.cog._report_cooldowns[interaction.user.id] = time.time() + + # Build embed for log + owner + report_em = discord.Embed( + title=f"🚨 Report #{counter} — {self.net_name}", + colour=discord.Colour.red(), + timestamp=datetime.now(timezone.utc), + ) + report_em.add_field(name="Reported User", value=f"{self.target.author} (`{self.target.author.id}`)", inline=True) + report_em.add_field(name="Reporter", value=f"{interaction.user} (`{interaction.user.id}`)", inline=True) + report_em.add_field(name="Reason", value=str(self.reason)[:1024], inline=False) + report_em.add_field(name="Message Content", value=f"```{truncate(self.target.content or '[no text]', 1000)}```", inline=False) + report_em.set_footer(text=f"Content hash: {content_hash}") + + await self.cog._log(nd, report_em) + owner = self.cog.bot.get_user(nd.get("owner_id")) + if owner: + try: await owner.send(embed=report_em) + except: pass + + await interaction.response.send_message(f"🚨 Report #{counter} submitted. Staff notified.", ephemeral=True) + + +class Wormhole(commands.Cog): + """The ultimate cross-server relay: networks, DMs, starboard, auto-mod, invites, portals & more.""" + + __version__ = "3.4.6" + + 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] = {} + self.slowmode_tracker: Dict[str, Dict[int, float]] = defaultdict(dict) # {network: {user_id: last_time}} + self.dup_detectors: Dict[str, DuplicateDetector] = {} + self.raid_detectors: Dict[str, RaidDetector] = {} + self.msg_map = _MessageMap() + self._autoresponse_cooldowns: Dict[str, float] = {} # "net:pattern" -> last_trigger + self._ephemeral_tasks: List[asyncio.Task] = [] + self._report_cooldowns: Dict[int, float] = {} # user_id -> last report timestamp + + # Context menus (registered in _init after bot ready) + self._ctx_menus: List[app_commands.ContextMenu] = [] + + self._ready = asyncio.Event() + self._bg_tasks: List[asyncio.Task] = [] + self._trace_channels: set = set() # channel IDs with live relay tracing + self._startup_task = asyncio.ensure_future(self._init()) + + async def _init(self): + try: + await self.bot.wait_until_ready() + # ── Migration v3.4.3: ensure mention_policy defaults + optout list ── + async with self.config.networks() as nets: + for name, data in nets.items(): + mp = data.setdefault("mention_policy", {}) + changed = False + # One-time migration: if no _mention_migrated flag, force all off + if not data.get("_mention_migrated"): + for key in ("allow_user_mentions", "allow_role_mentions", "allow_everyone", "allow_here"): + mp[key] = False + data["_mention_migrated"] = True + changed = True + # Ensure optout list exists + data.setdefault("mention_optout_users", []) + if changed: + log.info("Wormhole migration: disabled all mentions for network '%s'", name) + 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)) + am = data.get("automod", {}) + self.dup_detectors[name] = DuplicateDetector(am.get("spam_window", 30.0), am.get("spam_threshold", 3)) + self.raid_detectors[name] = RaidDetector(am.get("raid_window", 60.0), am.get("raid_threshold", 10)) + self._bg_tasks.append(asyncio.ensure_future(self._blackout_loop())) + self._bg_tasks.append(asyncio.ensure_future(self._portal_update_loop())) + self._bg_tasks.append(asyncio.ensure_future(self._scheduled_msg_loop())) + self._bg_tasks.append(asyncio.ensure_future(self._health_check_loop())) + self._bg_tasks.append(asyncio.ensure_future(self._poll_expiry_loop())) + # Register context menus + self._register_context_menus() + log.info("Wormhole v3.4.0 ready — %d networks loaded.", len(networks)) + except Exception as exc: + log.error("Wormhole init error (relay will still work): %s", exc, exc_info=True) + finally: + # ALWAYS set ready so the relay isn't permanently stuck + self._ready.set() + + def _register_context_menus(self): + """Register right-click context menu actions.""" + # ── Report Message ── + report_menu = app_commands.ContextMenu(name="Report to Wormhole", callback=self._ctx_report_message) + self.bot.tree.add_command(report_menu) + self._ctx_menus.append(report_menu) + + # ── Bookmark Message ── + bookmark_menu = app_commands.ContextMenu(name="Wormhole Bookmark", callback=self._ctx_bookmark_message) + self.bot.tree.add_command(bookmark_menu) + self._ctx_menus.append(bookmark_menu) + + # ── Delete from Network (staff) ── + delete_menu = app_commands.ContextMenu(name="Wormhole Delete", callback=self._ctx_delete_message) + self.bot.tree.add_command(delete_menu) + self._ctx_menus.append(delete_menu) + + # ── View Wormhole Profile ── + profile_menu = app_commands.ContextMenu(name="Wormhole Profile", callback=self._ctx_view_profile) + self.bot.tree.add_command(profile_menu) + self._ctx_menus.append(profile_menu) + + # ── Standalone slash commands for common operations ── + @app_commands.command(name="wormhole-list", description="List all wormhole networks") + async def slash_list(interaction: discord.Interaction): + ctx = await commands.Context.from_interaction(interaction) + await self.wh_list(ctx) + self.bot.tree.add_command(slash_list) + self._ctx_menus.append(slash_list) # reuse list for cleanup + + @app_commands.command(name="wormhole-info", description="View wormhole network info") + @app_commands.describe(name="Network name") + async def slash_info(interaction: discord.Interaction, name: str): + ctx = await commands.Context.from_interaction(interaction) + await self.wh_info(ctx, name) + self.bot.tree.add_command(slash_info) + self._ctx_menus.append(slash_info) + + @app_commands.command(name="wormhole-accept", description="View & accept a network's Terms of Service") + @app_commands.describe(name="Network name") + async def slash_accept(interaction: discord.Interaction, name: str): + ctx = await commands.Context.from_interaction(interaction) + await self.wh_accept(ctx, name) + self.bot.tree.add_command(slash_accept) + self._ctx_menus.append(slash_accept) + + @app_commands.command(name="wormhole-agree", description="Confirm acceptance of a network's ToS") + @app_commands.describe(name="Network name") + async def slash_agree(interaction: discord.Interaction, name: str): + ctx = await commands.Context.from_interaction(interaction) + await self.wh_agree(ctx, name) + self.bot.tree.add_command(slash_agree) + self._ctx_menus.append(slash_agree) + + @app_commands.command(name="wormhole-report", description="Report a wormhole message") + @app_commands.describe(message_id="Message ID to report", reason="Reason for report") + async def slash_report(interaction: discord.Interaction, message_id: str, reason: str = "No reason provided"): + ctx = await commands.Context.from_interaction(interaction) + await self.wh_report_msg(ctx, int(message_id), reason=reason) + self.bot.tree.add_command(slash_report) + self._ctx_menus.append(slash_report) + + @app_commands.command(name="wormhole-profile", description="View a user's wormhole profile") + @app_commands.describe(name="Network name", user="User to look up (optional)") + async def slash_profile(interaction: discord.Interaction, name: str, user: discord.User = None): + ctx = await commands.Context.from_interaction(interaction) + await self.wh_profile(ctx, name, user) + self.bot.tree.add_command(slash_profile) + self._ctx_menus.append(slash_profile) + + @app_commands.command(name="wormhole-discover", description="Browse public wormhole networks") + async def slash_discover(interaction: discord.Interaction): + ctx = await commands.Context.from_interaction(interaction) + await self.wh_discover(ctx) + self.bot.tree.add_command(slash_discover) + self._ctx_menus.append(slash_discover) + + async def cog_unload(self): + self._startup_task.cancel() + for t in self._bg_tasks: + t.cancel() + # Remove context menus + for menu in self._ctx_menus: + self.bot.tree.remove_command(menu.name, type=menu.type) + self._ctx_menus.clear() + + # ── Background loops ──────────────────────────────────────────────────── + + async def _blackout_loop(self): + await self._ready.wait() + while True: + try: + await asyncio.sleep(60) + now_utc = datetime.now(timezone.utc) + h, d = now_utc.hour, now_utc.weekday() + async with self.config.networks() as networks: + for name, data in networks.items(): + scheds = data.get("blackout_schedules", []) + if not scheds: + continue + should = False + for s in scheds: + if d in s.get("days", list(range(7))): + st, en = s.get("start_hour", 0), s.get("end_hour", 0) + if st <= en: + should = should or (st <= h < en) + else: + should = should or (h >= st or h < en) + if should and not data.get("frozen"): + networks[name]["frozen"] = True + networks[name]["_frozen_by"] = "blackout" + elif not should and data.get("frozen") and data.get("_frozen_by") == "blackout": + # Only unfreeze if the blackout loop froze it — never override manual freeze + networks[name]["frozen"] = False + networks[name].pop("_frozen_by", None) + except asyncio.CancelledError: + break + except Exception as e: + log.error("Blackout loop: %s", e) + await asyncio.sleep(60) + + async def _portal_update_loop(self): + await self._ready.wait() + while True: + try: + await asyncio.sleep(300) + networks = await self.config.networks() + for name, data in networks.items(): + portals = data.get("portal_messages", {}) + if not portals: + continue + embed = build_portal_embed(name, data, len(data["channels"]), data.get("total_messages", 0)) + dead = [] + for cid_s, mid in portals.items(): + ch = self.bot.get_channel(int(cid_s)) + if not ch: + dead.append(cid_s); continue + try: + msg = await ch.fetch_message(mid) + await msg.edit(embed=embed) + except discord.NotFound: + dead.append(cid_s) + except Exception: + pass + if dead: + async with self.config.networks() as nets: + if name in nets: + for c in dead: + nets[name].get("portal_messages", {}).pop(c, None) + except asyncio.CancelledError: + break + except Exception as e: + log.error("Portal loop: %s", e) + await asyncio.sleep(300) + + async def _scheduled_msg_loop(self): + await self._ready.wait() + while True: + try: + await asyncio.sleep(30) + now = datetime.now(timezone.utc) + async with self.config.networks() as networks: + for name, data in networks.items(): + sched = data.get("scheduled_messages", []) + remaining = [] + for msg_data in sched: + send_at = datetime.fromisoformat(msg_data["send_at"]) + if now >= send_at: + # Send it + for ch_id in data["channels"]: + ch = self.bot.get_channel(ch_id) + if ch: + try: + em = announce_embed(msg_data["content"], title=f"📅 Scheduled — {name}") + await ch.send(embed=em) + except Exception: + pass + else: + remaining.append(msg_data) + networks[name]["scheduled_messages"] = remaining + except asyncio.CancelledError: + break + except Exception as e: + log.error("Scheduled msg loop: %s", e) + await asyncio.sleep(30) + + async def _health_check_loop(self): + """Periodically check that all linked channels are still accessible.""" + await self._ready.wait() + while True: + try: + await asyncio.sleep(900) # every 15 min + async with self.config.networks() as networks: + for name, data in networks.items(): + unhealthy = [] + for ch_id in data.get("channels", []): + ch = self.bot.get_channel(ch_id) + if not ch: + unhealthy.append(ch_id) + continue + perms = ch.permissions_for(ch.guild.me) + if not perms.send_messages: + unhealthy.append(ch_id) + networks[name]["unhealthy_channels"] = unhealthy + networks[name]["last_health_check"] = datetime.now(timezone.utc).isoformat() + except asyncio.CancelledError: + break + except Exception as e: + log.error("Health check loop: %s", e) + await asyncio.sleep(900) + + async def _poll_expiry_loop(self): + """Check for expired polls and close them.""" + await self._ready.wait() + while True: + try: + await asyncio.sleep(60) + now = datetime.now(timezone.utc) + async with self.config.networks() as networks: + for name, data in networks.items(): + polls = data.get("active_polls", {}) + expired = [] + for pid, poll in polls.items(): + if poll.get("expires"): + exp = datetime.fromisoformat(poll["expires"]) + if now >= exp: + expired.append(pid) + for pid in expired: + poll = polls.pop(pid) + # Announce results + results = self._format_poll_results(poll) + em = info_embed(results, title=f"📊 Poll Closed — {poll.get('question', '?')}") + for ch_id in data.get("channels", []): + ch = self.bot.get_channel(ch_id) + if ch: + try: await ch.send(embed=em) + except: pass + networks[name]["active_polls"] = polls + except asyncio.CancelledError: + break + except Exception as e: + log.error("Poll expiry loop: %s", e) + await asyncio.sleep(60) + + @staticmethod + def _format_poll_results(poll: dict) -> str: + options = poll.get("options", []) + votes = poll.get("votes", {}) + total = sum(len(v) for v in votes.values()) + lines = [] + emojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"] + for i, opt in enumerate(options): + count = len(votes.get(str(i), [])) + pct = (count / total * 100) if total else 0 + bar_len = int(pct / 5) + bar = "█" * bar_len + "░" * (20 - bar_len) + lines.append(f"{emojis[i] if i < len(emojis) else '•'} **{opt}**\n{bar} {count} ({pct:.0f}%)") + lines.append(f"\n**Total votes:** {total}") + return "\n".join(lines) + + # ── Helpers ───────────────────────────────────────────────────────────── + + async def _net(self, name: str) -> Optional[dict]: + return (await self.config.networks()).get(name) + + async def _save(self, name: str, data: dict): + async with self.config.networks() as n: + n[name] = data + + async def _net_for_ch(self, ch_id: int) -> Optional[str]: + for n, d in (await self.config.networks()).items(): + if ch_id in d.get("channels", []): + return n + return None + + async def _wh(self, ch: discord.TextChannel, *, force_refresh: bool = False) -> discord.Webhook: + if not force_refresh and ch.id in self.webhook_cache: + return self.webhook_cache[ch.id] + try: + for w in await ch.webhooks(): + if w.user and w.user.id == self.bot.user.id and w.name == "Wormhole Relay": + self.webhook_cache[ch.id] = w + return w + w = await ch.create_webhook(name="Wormhole Relay") + self.webhook_cache[ch.id] = w + return w + except discord.Forbidden: + self.webhook_cache.pop(ch.id, None) + raise + except Exception: + self.webhook_cache.pop(ch.id, None) + raise + + def _avatar(self, msg, mode, icon): + if mode == "server": + i = msg.guild.icon + return i.url if i else msg.author.display_avatar.url + if mode == "custom" and icon: + return icon + return msg.author.display_avatar.url + + def _name(self, msg, mode, custom, nick=None): + g = nick or msg.guild.name + u = msg.author.display_name + if mode == "server": return g + if mode == "both": return f"{g} • {u}" + if mode == "custom" and custom: return custom.replace("{user}", u).replace("{server}", g) + return u + + async def _is_staff(self, d, uid): return uid == d["owner_id"] or uid in d.get("staff_ids", []) + async def _is_owner(self, d, uid): return uid == d["owner_id"] + + def _get_override(self, net_data, ch_id, key): + """Get per-channel override or fall back to network setting.""" + overrides = net_data.get("channel_overrides", {}).get(str(ch_id), {}) + if key in overrides: + return overrides[key] + return net_data.get(key) + + async def _log(self, d, embed): + ch_id = d.get("log_channel") + if ch_id: + ch = self.bot.get_channel(ch_id) + if ch: + try: await ch.send(embed=embed) + except Exception: pass + + async def _audit(self, name, action, user, target="", details=""): + entry = format_audit_entry(action, str(user), target, details) + async with self.config.networks() as nets: + if name in nets: + al = nets[name].setdefault("audit_log", []) + al.append(entry) + if len(al) > _AUDIT_LIMIT: + nets[name]["audit_log"] = al[-_AUDIT_LIMIT:] + + async def _status(self, net_name, net_data, src_ch, text): + if net_data.get("silent"): return + em = info_embed(text, title=f"🌀 {net_name}") + for ch_id in net_data["channels"]: + if ch_id == (src_ch.id if src_ch else 0): continue + ch = self.bot.get_channel(ch_id) + if ch: + try: await ch.send(embed=em) + except Exception: pass + + async def _update_profile(self, name, user, guild_id): + uid = str(user.id) + async with self.config.networks() as nets: + if name not in nets: return + p = nets[name].setdefault("user_profiles", {}) + if uid not in p: + p[uid] = {"messages": 0, "first_seen": datetime.now(timezone.utc).isoformat(), "servers": []} + p[uid]["messages"] += 1 + if guild_id not in p[uid].get("servers", []): + p[uid].setdefault("servers", []).append(guild_id) + + async def _check_highlights(self, net_name, net_data, content, author_id): + """DM users who have keyword highlights matching this message.""" + highlights = net_data.get("highlights", {}) + if not highlights or not content: + return + lower = content.lower() + for uid_str, keywords in highlights.items(): + uid = int(uid_str) + if uid == author_id: + continue + for kw in keywords: + if kw.lower() in lower: + user = self.bot.get_user(uid) + if user: + try: + em = info_embed( + f"Keyword **{kw}** was mentioned in **{net_name}**:\n\n> {truncate(content, 300)}", + title="🔔 Highlight" + ) + await user.send(embed=em) + except Exception: + pass + break # one DM per message per user + + def _anon_name(self, net_data: dict, user_id: int) -> str: + """Generate a consistent anonymous name for a user within a network.""" + salt = net_data.get("anon_salt", "wormhole") + h = hashlib.sha256(f"{salt}:{user_id}".encode()).hexdigest()[:6] + return f"Anon#{h.upper()}" + + def _anon_avatar(self, user_id: int) -> str: + """Generate a consistent avatar URL for anonymous users.""" + idx = user_id % 5 + return f"https://cdn.discordapp.com/embed/avatars/{idx}.png" + + async def _record_analytics(self, net_name: str, user_id: int): + """Record a message for analytics.""" + now = datetime.now(timezone.utc) + hour_key = now.strftime("%Y-%m-%d-%H") + uid_str = str(user_id) + async with self.config.networks() as nets: + if net_name not in nets: + return + analytics = nets[net_name].setdefault("analytics", {"hourly": {}, "top_users": {}}) + analytics["hourly"][hour_key] = analytics["hourly"].get(hour_key, 0) + 1 + analytics["top_users"][uid_str] = analytics["top_users"].get(uid_str, 0) + 1 + # Prune old hourly data (keep last 7 days = 168 hours) + if len(analytics["hourly"]) > 200: + sorted_keys = sorted(analytics["hourly"].keys()) + for k in sorted_keys[:-168]: + analytics["hourly"].pop(k, None) + + async def _check_auto_responses(self, net_name: str, net_data: dict, message: discord.Message): + """Check and trigger auto-responses.""" + auto_resp = net_data.get("auto_responses", {}) + if not auto_resp: + return + for pattern, cfg in auto_resp.items(): + cooldown_key = f"{net_name}:{pattern}" + cooldown = cfg.get("cooldown", 30) + last = self._autoresponse_cooldowns.get(cooldown_key, 0) + if time.monotonic() - last < cooldown: + continue + matched = False + if cfg.get("regex"): + try: + if re.search(pattern, message.content, re.IGNORECASE): + matched = True + except re.error: + pass + else: + if pattern.lower() in message.content.lower(): + matched = True + if matched: + self._autoresponse_cooldowns[cooldown_key] = time.monotonic() + reply = cfg.get("reply", "") + if reply: + reply = reply.replace("{user}", message.author.display_name).replace("{server}", message.guild.name) + try: + await message.channel.send(embed=info_embed(reply, title="🤖 Auto-Response")) + except: + pass + break # one auto-response per message + + async def _schedule_ephemeral_delete(self, msg: discord.Message, delay: int): + """Schedule a message for deletion after delay seconds.""" + async def _delete_after(): + await asyncio.sleep(delay) + try: + await msg.delete() + except: + pass + task = asyncio.ensure_future(_delete_after()) + self._ephemeral_tasks.append(task) + # Clean up finished tasks + self._ephemeral_tasks = [t for t in self._ephemeral_tasks if not t.done()] + + async def _check_afk(self, net_name: str, net_data: dict, message: discord.Message): + """Check if message author was AFK (clear it) and if message mentions AFK users.""" + afk = net_data.get("afk_users", {}) + uid_str = str(message.author.id) + # Clear AFK if user sends a message + if uid_str in afk: + async with self.config.networks() as nets: + if net_name in nets: + nets[net_name].get("afk_users", {}).pop(uid_str, None) + since = afk[uid_str].get("since", "?") + try: + await message.channel.send( + embed=info_embed(f"Welcome back, **{message.author.display_name}**! AFK removed.", title="💤"), + delete_after=10 + ) + except: + pass + # Notify if mentioned users are AFK + for user in message.mentions: + u_str = str(user.id) + if u_str in afk: + reason = afk[u_str].get("reason", "No reason given") + since = afk[u_str].get("since", "Unknown")[:16] + try: + await message.channel.send( + embed=info_embed(f"**{user.display_name}** is AFK: {reason}\n*Since {since}*", title="💤 AFK"), + delete_after=15 + ) + except: + pass + + def _is_quiet_hour(self, net_data: dict, user_id: int) -> bool: + """Check if a user is in their quiet hours right now.""" + qh = net_data.get("quiet_hours", {}).get(str(user_id)) + if not qh: + return False + tz_offset = qh.get("tz_offset", 0) + now_user = datetime.now(timezone.utc) + timedelta(hours=tz_offset) + h = now_user.hour + start = qh.get("start_hour", 0) + end = qh.get("end_hour", 0) + if start <= end: + return start <= h < end + else: + return h >= start or h < end + + async def _relay_to_dm_subs(self, net_name, net_data, message): + """Forward a network message to all DM subscribers.""" + if not net_data.get("dm_enabled"): + return + subs = net_data.get("dm_subscribers", []) + if not subs: + return + dm_mode = net_data.get("dm_relay_mode", "embed") + is_anon = net_data.get("anonymous", False) + for uid in subs: + if uid == message.author.id: + continue + # Personal ignore list + ignores = net_data.get("user_ignores", {}).get(str(uid), []) + if message.author.id in ignores: + continue + # Quiet hours + if self._is_quiet_hour(net_data, uid): + continue + user = self.bot.get_user(uid) + if not user: + continue + try: + nick = net_data.get("server_nicknames", {}).get(str(message.guild.id)) + display_name = self._anon_name(net_data, message.author.id) if is_anon else message.author.display_name + display_avatar = self._anon_avatar(message.author.id) if is_anon else message.author.display_avatar.url + if dm_mode == "embed": + em = build_dm_incoming_embed( + display_name, + display_avatar, + nick or message.guild.name, + message.channel.name, + message.content, + net_name, + net_data.get("colour"), + ) + await user.send(embed=em) + elif dm_mode == "compact": + server = nick or message.guild.name + text = f"**[{server}] {display_name}:** {truncate(message.content, 1800)}" + await user.send(text) + else: + await user.send(f"**{display_name}** ({message.guild.name}): {truncate(message.content, 1800)}") + except Exception: + pass + + # ── Main group ────────────────────────────────────────────────────────── + + @commands.group(name="wh", aliases=["wormhole"], invoke_without_command=True) + async def wh(self, ctx: commands.Context): + """🌀 Wormhole — the ultimate cross-server relay. Use `[p]wh help` for commands.""" + await ctx.send_help(ctx.command) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # NETWORK MANAGEMENT + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="create") + async def wh_create(self, ctx, name: str, *, description: str = ""): + """Create a new wormhole network.""" + name = name.lower().strip() + if not name.replace("-", "").replace("_", "").isalnum() or len(name) > 32: + return await ctx.send(embed=err_embed("Name: alphanumeric/hyphens/underscores, max 32 chars.")) + nets = await self.config.networks() + if name in nets: + return await ctx.send(embed=err_embed(f"**{name}** already exists.")) + mx = await self.config.max_networks_per_user() + if sum(1 for n in nets.values() if n["owner_id"] == ctx.author.id) >= mx: + return await ctx.send(embed=err_embed(f"You own the max of {mx} networks.")) + d = deepcopy(_DEFAULT_NETWORK) + d["owner_id"] = ctx.author.id + d["description"] = description + d["created_at"] = datetime.now(timezone.utc).isoformat() + await self._save(name, d) + self.cooldowns[name] = CooldownBucket(d["rate_limit_rate"], d["rate_limit_per"]) + self.dup_detectors[name] = DuplicateDetector() + self.raid_detectors[name] = RaidDetector() + p = ctx.clean_prefix + await ctx.send(embed=ok_embed( + f"Network **{name}** created!\n\n" + f"`{p}wh open {name}` — link a channel\n" + f"`{p}wh set {name} ...` — customise\n" + f"`{p}wh invite create {name}` — share invite\n" + f"`{p}wh dm enable {name}` — enable DM relay", + title="🌀 Network Created")) + await self._audit(name, "create", ctx.author) + + @wh.command(name="delete") + async def wh_delete(self, ctx, name: str): + """Delete a network (owner/bot-owner). Requires confirmation.""" + name = name.lower() + d = await self._net(name) + if not d: return await ctx.send(embed=err_embed(f"**{name}** not found.")) + if not await self._is_owner(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): + return await ctx.send(embed=err_embed("Owner or bot-owner only.")) + await ctx.send(embed=warn_embed(f"Type **`yes`** in 30 s to delete **{name}**.")) + try: + await self.bot.wait_for("message", check=lambda m: m.author == ctx.author and m.channel == ctx.channel and m.content.lower() == "yes", timeout=30) + except asyncio.TimeoutError: + return await ctx.send(embed=info_embed("Cancelled.")) + async with self.config.networks() as n: n.pop(name, None) + self.cooldowns.pop(name, None) + self.dup_detectors.pop(name, None) + self.raid_detectors.pop(name, None) + await ctx.send(embed=ok_embed(f"**{name}** deleted.")) + + @wh.command(name="open") + @commands.guild_only() + async def wh_open(self, ctx, name: str): + """Link this channel to a network.""" + name = name.lower() + d = await self._net(name) + if not d: return await ctx.send(embed=err_embed(f"**{name}** not found.")) + if d.get("allowlist_servers") and ctx.guild.id not in d["allowlist_servers"]: + return await ctx.send(embed=err_embed("Server not on allowlist.")) + if ctx.guild.id in d.get("banned_servers", []): + return await ctx.send(embed=err_embed("Server banned from network.")) + gbu = await self.config.global_banned_servers() + if ctx.guild.id in gbu: + return await ctx.send(embed=err_embed("Server globally blocked.")) + if ctx.channel.id in d["channels"]: + return await ctx.send(embed=err_embed("Already linked.")) + ex = await self._net_for_ch(ctx.channel.id) + if ex: return await ctx.send(embed=err_embed(f"Already in **{ex}**. Close first.")) + async with self.config.networks() as nets: + nets[name]["channels"].append(ctx.channel.id) + d = nets[name] + await ctx.send(embed=ok_embed(f"Linked to **{name}** ({len(d['channels'])} channels).")) + await self._status(name, d, ctx.channel, f"📡 **{ctx.guild.name}** › #{ctx.channel.name} joined.") + if d.get("welcome_message"): + await ctx.send(embed=info_embed(d["welcome_message"], title=f"👋 Welcome to {name}!")) + if d.get("motd"): + await ctx.send(embed=info_embed(d["motd"], title=f"📋 MOTD — {name}")) + if d.get("rules"): + await ctx.send(embed=info_embed(d["rules"], title=f"📜 Rules — {name}")) + await self._audit(name, "open", ctx.author, target=f"#{ctx.channel.name}") + + @wh.command(name="close") + @commands.guild_only() + async def wh_close(self, ctx, name: str = None): + """Unlink this channel.""" + if not name: + name = await self._net_for_ch(ctx.channel.id) + if not name: return await ctx.send(embed=err_embed("Not linked.")) + else: + name = name.lower() + d = await self._net(name) + if not d: return await ctx.send(embed=err_embed(f"**{name}** not found.")) + if ctx.channel.id not in d["channels"]: + return await ctx.send(embed=err_embed("Not linked.")) + async with self.config.networks() as nets: + nets[name]["channels"].remove(ctx.channel.id) + nets[name].get("portal_messages", {}).pop(str(ctx.channel.id), None) + d = nets[name] + self.webhook_cache.pop(ctx.channel.id, None) + await ctx.send(embed=ok_embed(f"Severed from **{name}**.")) + await self._status(name, d, ctx.channel, f"📡 **{ctx.guild.name}** › #{ctx.channel.name} left.") + await self._audit(name, "close", ctx.author, target=f"#{ctx.channel.name}") + + @wh.command(name="list") + async def wh_list(self, ctx): + """List all networks.""" + nets = await self.config.networks() + if not nets: return await ctx.send(embed=info_embed("No networks yet.")) + lines = [] + for n, d in sorted(nets.items()): + st = "❄️" if d.get("frozen") else f"✅{len(d['channels'])}ch" + mode = d.get("relay_mode", "webhook") + dm = " 📧" if d.get("dm_enabled") else "" + pub = " 🌍" if d.get("public") else "" + lines.append(f"**{n}** — {st} `{mode}`{dm}{pub}") + await ctx.send(embed=info_embed("\n".join(lines), title="🌀 Networks")) + + @wh.command(name="discover") + async def wh_discover(self, ctx): + """List public networks anyone can join.""" + nets = await self.config.networks() + public = {n: d for n, d in nets.items() if d.get("public")} + if not public: + return await ctx.send(embed=info_embed("No public networks available.")) + lines = [] + for n, d in sorted(public.items()): + desc = truncate(d.get("description") or "No description", 60) + ch = len(d["channels"]) + tags = " ".join(f"`{t}`" for t in d.get("tags", [])[:3]) + lines.append(f"**{n}** — {ch} channels — {desc} {tags}") + await ctx.send(embed=info_embed("\n".join(lines), title="🌍 Public Networks")) + + @wh.command(name="info") + async def wh_info(self, ctx, name: str): + """Show detailed network info.""" + name = name.lower() + d = await self._net(name) + if not d: return await ctx.send(embed=err_embed(f"**{name}** not found.")) + + owner = self.bot.get_user(d["owner_id"]) + colour = discord.Colour(d["colour"]) if d.get("colour") else COLOUR_INFO + em = discord.Embed(title=f"🌀 {name}", description=d.get("description") or "*No description.*", colour=colour) + em.add_field(name="Owner", value=str(owner or d["owner_id"]), inline=True) + em.add_field(name="Staff", value=str(len(d.get("staff_ids", []))), inline=True) + em.add_field(name="Channels", value=str(len(d["channels"])), inline=True) + em.add_field(name="Relayed", value=f"{d.get('total_messages', 0):,}", inline=True) + em.add_field(name="Mode", value=f"`{d.get('relay_mode', 'webhook')}`", inline=True) + em.add_field(name="Frozen", value="✅" if d.get("frozen") else "❌", inline=True) + + flags = [] + for f in ("sync_edits", "sync_deletes", "sync_reactions", "sync_replies", "sync_stickers", "sync_pins", "sync_typing", "forward_embeds", "dm_enabled", "starboard_enabled", "karma_enabled"): + if d.get(f): flags.append(f"✅ {f.replace('_', ' ').title()}") + if flags: em.add_field(name="Features", value="\n".join(flags), inline=False) + + em.add_field(name="Identity", value=f"Name: `{d['name_mode']}` Image: `{d['image_mode']}`", inline=True) + em.add_field(name="Rate limit", value=f"{d.get('rate_limit_rate',5)}/{d.get('rate_limit_per',10)}s", inline=True) + + if d.get("slowmode"): em.add_field(name="Slowmode", value=f"{d['slowmode']}s", inline=True) + if d.get("relay_delay"): em.add_field(name="Relay delay", value=f"{d['relay_delay']}s", inline=True) + dm_subs = len(d.get("dm_subscribers", [])) + if dm_subs: em.add_field(name="DM Subscribers", value=str(dm_subs), inline=True) + if d.get("vanity_invite"): em.add_field(name="Vanity", value=f"`{d['vanity_invite']}`", inline=True) + if d.get("motd"): em.add_field(name="MOTD", value=truncate(d["motd"], 100), inline=False) + + channels_list = [] + for ch_id in d["channels"][:15]: + ch = self.bot.get_channel(ch_id) + channels_list.append(f"• **{ch.guild.name}** › #{ch.name}" if ch else f"• `{ch_id}`") + if channels_list: em.add_field(name="Channels", value="\n".join(channels_list), inline=False) + if d.get("created_at"): em.set_footer(text=f"Created {d['created_at'][:10]} • v{self.__version__}") + if d.get("custom_icon"): em.set_thumbnail(url=d["custom_icon"]) + await ctx.send(embed=em) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # SETTINGS + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="set", invoke_without_command=True) + async def wh_set(self, ctx): + """Customise network settings.""" + await ctx.send_help(ctx.command) + + async def _toggle(self, ctx, name, key, value): + name = name.lower() + d = await self._net(name) + if not d: return await ctx.send(embed=err_embed(f"**{name}** not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): + return await ctx.send(embed=err_embed("Staff/owner only.")) + async with self.config.networks() as n: n[name][key] = value + await ctx.send(embed=ok_embed(f"**{key.replace('_',' ').title()}** {'enabled' if value else 'disabled'} for **{name}**.")) + await self._audit(name, f"set {key}", ctx.author, details=str(value)) + + # All the toggle commands + @wh_set.command(name="relay-mode") + async def wh_set_relay_mode(self, ctx, name: str, mode: str): + """Set relay mode: webhook | embed | compact""" + if mode.lower() not in ("webhook", "embed", "compact"): + return await ctx.send(embed=err_embed("Must be `webhook`, `embed`, or `compact`.")) + name = name.lower() + d = await self._net(name) + if not d: return await ctx.send(embed=err_embed(f"**{name}** not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): + return await ctx.send(embed=err_embed("Staff/owner only.")) + async with self.config.networks() as n: + n[name]["relay_mode"] = mode.lower() + n[name]["use_webhooks"] = mode.lower() == "webhook" + await ctx.send(embed=ok_embed(f"Relay mode → `{mode}` for **{name}**.")) + + @wh_set.command(name="webhooks") + async def wh_set_wh(self, ctx, name: str, toggle: bool): await self._toggle(ctx, name, "use_webhooks", toggle) + @wh_set.command(name="sync-edits") + async def wh_set_se(self, ctx, name: str, toggle: bool): await self._toggle(ctx, name, "sync_edits", toggle) + @wh_set.command(name="sync-deletes") + async def wh_set_sd(self, ctx, name: str, toggle: bool): await self._toggle(ctx, name, "sync_deletes", toggle) + @wh_set.command(name="sync-reactions") + async def wh_set_sr(self, ctx, name: str, toggle: bool): await self._toggle(ctx, name, "sync_reactions", toggle) + @wh_set.command(name="sync-replies") + async def wh_set_srp(self, ctx, name: str, toggle: bool): await self._toggle(ctx, name, "sync_replies", toggle) + @wh_set.command(name="sync-stickers") + async def wh_set_ss(self, ctx, name: str, toggle: bool): await self._toggle(ctx, name, "sync_stickers", toggle) + @wh_set.command(name="sync-threads") + async def wh_set_st(self, ctx, name: str, toggle: bool): await self._toggle(ctx, name, "sync_threads", toggle) + @wh_set.command(name="sync-pins") + async def wh_set_sp(self, ctx, name: str, toggle: bool): + """Toggle pin synchronisation across channels.""" + await self._toggle(ctx, name, "sync_pins", toggle) + @wh_set.command(name="sync-typing") + async def wh_set_styp(self, ctx, name: str, toggle: bool): + """Toggle typing indicator relay.""" + await self._toggle(ctx, name, "sync_typing", toggle) + @wh_set.command(name="forward-embeds") + async def wh_set_fe(self, ctx, name: str, toggle: bool): await self._toggle(ctx, name, "forward_embeds", toggle) + @wh_set.command(name="nsfw-gate") + async def wh_set_nsfw(self, ctx, name: str, toggle: bool): await self._toggle(ctx, name, "nsfw_gate", toggle) + @wh_set.command(name="silent") + async def wh_set_sil(self, ctx, name: str, toggle: bool): await self._toggle(ctx, name, "silent", toggle) + @wh_set.command(name="freeze") + async def wh_set_frz(self, ctx, name: str, toggle: bool): await self._toggle(ctx, name, "frozen", toggle) + @wh_set.command(name="public") + async def wh_set_pub(self, ctx, name: str, toggle: bool): + """Make the network discoverable.""" + await self._toggle(ctx, name, "public", toggle) + + @wh_set.command(name="name-mode") + async def wh_set_nm(self, ctx, name: str, mode: str): + """user | server | both | custom""" + if mode.lower() not in ("user", "server", "both", "custom"): + return await ctx.send(embed=err_embed("Invalid mode.")) + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name]["name_mode"] = mode.lower() + await ctx.send(embed=ok_embed(f"Name mode → `{mode}`.")) + + @wh_set.command(name="image-mode") + async def wh_set_im(self, ctx, name: str, mode: str): + """user | server | custom""" + if mode.lower() not in ("user", "server", "custom"): + return await ctx.send(embed=err_embed("Invalid mode.")) + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name]["image_mode"] = mode.lower() + await ctx.send(embed=ok_embed(f"Image mode → `{mode}`.")) + + @wh_set.command(name="custom-icon") + async def wh_set_ci(self, ctx, name: str, url: str): + """Set custom icon URL.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name]["custom_icon"] = url + await ctx.send(embed=ok_embed("Icon set.")) + + @wh_set.command(name="custom-name") + async def wh_set_cn(self, ctx, name: str, *, template: str): + """Set name template ({user}, {server}).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name]["custom_name"] = template + await ctx.send(embed=ok_embed(f"Template → `{template}`.")) + + @wh_set.command(name="description") + async def wh_set_desc(self, ctx, name: str, *, text: str): + """Set network description.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name]["description"] = text[:1024] + await ctx.send(embed=ok_embed("Description updated.")) + + @wh_set.command(name="colour", aliases=["color"]) + async def wh_set_col(self, ctx, name: str, hex_colour: str): + """Set accent colour (#hex).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + try: val = int(hex_colour.strip("#"), 16) + except ValueError: return await ctx.send(embed=err_embed("Invalid hex.")) + async with self.config.networks() as n: n[name]["colour"] = val + await ctx.send(embed=ok_embed("Colour set.")) + + @wh_set.command(name="ratelimit") + async def wh_set_rl(self, ctx, name: str, rate: int, per: float): + """Set rate limit: per .""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name]["rate_limit_rate"] = rate; n[name]["rate_limit_per"] = per + self.cooldowns.setdefault(name, CooldownBucket()).update(rate, per) + await ctx.send(embed=ok_embed(f"Rate limit → {rate}/{per}s.")) + + @wh_set.command(name="slowmode") + async def wh_set_slow(self, ctx, name: str, seconds: int): + """Set network-wide slowmode (0 to disable).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name]["slowmode"] = max(0, seconds) + await ctx.send(embed=ok_embed(f"Slowmode → {seconds}s.")) + + @wh_set.command(name="relay-delay") + async def wh_set_delay(self, ctx, name: str, seconds: int): + """Set relay delay in seconds (0=instant, max 30).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name]["relay_delay"] = min(30, max(0, seconds)) + await ctx.send(embed=ok_embed(f"Relay delay → {seconds}s.")) + + @wh_set.command(name="log-channel") + async def wh_set_log(self, ctx, name: str, channel: discord.TextChannel = None): + """Set/clear log channel.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name]["log_channel"] = channel.id if channel else None + await ctx.send(embed=ok_embed(f"Log → {channel.mention if channel else 'disabled'}.")) + + @wh_set.command(name="nickname") + async def wh_set_nick(self, ctx, name: str, *, nickname: str): + """Set server nickname in network.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + async with self.config.networks() as n: + n[name].setdefault("server_nicknames", {})[str(ctx.guild.id)] = nickname + await ctx.send(embed=ok_embed(f"This server → **{nickname}** in **{name}**.")) + + @wh_set.command(name="welcome") + async def wh_set_welc(self, ctx, name: str, *, msg: str): + """Set welcome message.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name]["welcome_message"] = msg[:1024] + await ctx.send(embed=ok_embed("Welcome message set.")) + + @wh_set.command(name="motd") + async def wh_set_motd(self, ctx, name: str, *, text: str): + """Set message of the day.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name]["motd"] = text[:1024] + await ctx.send(embed=ok_embed("MOTD set.")) + + @wh_set.command(name="rules") + async def wh_set_rules(self, ctx, name: str, *, text: str): + """Set network rules.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name]["rules"] = text[:2048] + await ctx.send(embed=ok_embed("Rules set.")) + + @wh_set.command(name="tags") + async def wh_set_tags(self, ctx, name: str, *, tags: str): + """Set discovery tags (comma-separated).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + tag_list = [t.strip().lower() for t in tags.split(",") if t.strip()][:10] + async with self.config.networks() as n: n[name]["tags"] = tag_list + await ctx.send(embed=ok_embed(f"Tags → {', '.join(tag_list)}")) + + @wh_set.command(name="max-filesize") + async def wh_set_maxfs(self, ctx, name: str, megabytes: float): + """Set max file size in MB (0 to disable).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + val = int(megabytes * 1024 * 1024) if megabytes > 0 else None + async with self.config.networks() as n: n[name]["max_filesize"] = val + await ctx.send(embed=ok_embed(f"Max filesize → {megabytes} MB." if val else "Filesize limit disabled.")) + + @wh_set.command(name="blocked-extensions") + async def wh_set_exts(self, ctx, name: str, *, extensions: str): + """Set blocked file extensions (comma-separated, e.g. .exe,.bat).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + exts = [e.strip().lower() for e in extensions.split(",") if e.strip()] + async with self.config.networks() as n: n[name]["blocked_extensions"] = exts + await ctx.send(embed=ok_embed(f"Blocked extensions → {', '.join(exts)}")) + + # ── Mention control ───────────────────────────────────────────────────── + + @wh_set.command(name="strip-everyone") + async def wh_set_se2(self, ctx, name: str, toggle: bool): + """Strip @everyone/@here.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name].setdefault("mention_control", {})["strip_everyone"] = toggle + await ctx.send(embed=ok_embed(f"@everyone stripping {'on' if toggle else 'off'}.")) + + @wh_set.command(name="strip-roles") + async def wh_set_sr2(self, ctx, name: str, toggle: bool): + """Strip @role mentions.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name].setdefault("mention_control", {})["strip_role_mentions"] = toggle + await ctx.send(embed=ok_embed(f"@role stripping {'on' if toggle else 'off'}.")) + + @wh_set.command(name="strip-users") + async def wh_set_su(self, ctx, name: str, toggle: bool): + """Strip @user mentions.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name].setdefault("mention_control", {})["strip_user_mentions"] = toggle + await ctx.send(embed=ok_embed(f"@user stripping {'on' if toggle else 'off'}.")) + + # ── Per-channel overrides ──────────────────────────────────────────────── + + @wh_set.command(name="channel-override") + @commands.guild_only() + async def wh_set_override(self, ctx, name: str, key: str, value: str): + """Set a per-channel override for a setting (e.g. relay_mode, name_mode).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + # Parse value + if value.lower() in ("true", "false"): + parsed = value.lower() == "true" + else: + try: parsed = int(value) + except ValueError: + try: parsed = float(value) + except ValueError: parsed = value + async with self.config.networks() as n: + n[name].setdefault("channel_overrides", {}).setdefault(str(ctx.channel.id), {})[key] = parsed + await ctx.send(embed=ok_embed(f"Override for this channel: `{key}` = `{parsed}`.")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # DM RELAY + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="dm", invoke_without_command=True) + async def wh_dm(self, ctx): + """DM relay — send/receive wormhole messages via DMs.""" + await ctx.send_help(ctx.command) + + @wh_dm.command(name="enable") + async def wh_dm_enable(self, ctx, name: str): + """Enable DM relay for a network (staff only).""" + await self._toggle(ctx, name, "dm_enabled", True) + + @wh_dm.command(name="disable") + async def wh_dm_disable(self, ctx, name: str): + """Disable DM relay for a network (staff only).""" + await self._toggle(ctx, name, "dm_enabled", False) + + @wh_dm.command(name="mode") + async def wh_dm_mode(self, ctx, name: str, mode: str): + """Set DM relay format: embed | compact | plain""" + if mode.lower() not in ("embed", "compact", "plain"): + return await ctx.send(embed=err_embed("Must be `embed`, `compact`, or `plain`.")) + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name]["dm_relay_mode"] = mode.lower() + await ctx.send(embed=ok_embed(f"DM relay mode → `{mode}`.")) + + @wh_dm.command(name="subscribe", aliases=["sub"]) + async def wh_dm_sub(self, ctx, name: str): + """Subscribe to receive network messages via DM.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not d.get("dm_enabled"): + return await ctx.send(embed=err_embed("DM relay not enabled for this network.")) + if ctx.author.id in d.get("banned_users", []): + return await ctx.send(embed=err_embed("You're banned from this network.")) + if ctx.author.id in d.get("dm_subscribers", []): + return await ctx.send(embed=info_embed("Already subscribed.")) + async with self.config.networks() as n: + n[name].setdefault("dm_subscribers", []).append(ctx.author.id) + await ctx.send(embed=ok_embed(f"You'll now receive **{name}** messages in your DMs.\nSend messages with: `{ctx.clean_prefix}wh dm send {name} `")) + + @wh_dm.command(name="unsubscribe", aliases=["unsub"]) + async def wh_dm_unsub(self, ctx, name: str): + """Unsubscribe from DM relay.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if ctx.author.id not in d.get("dm_subscribers", []): + return await ctx.send(embed=info_embed("Not subscribed.")) + async with self.config.networks() as n: + if ctx.author.id in n[name].get("dm_subscribers", []): + n[name]["dm_subscribers"].remove(ctx.author.id) + await ctx.send(embed=ok_embed(f"Unsubscribed from **{name}** DMs.")) + + @wh_dm.command(name="send") + async def wh_dm_send(self, ctx, name: str, *, message: str): + """Send a message to a network from anywhere (DMs or channel).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not d.get("dm_enabled"): + return await ctx.send(embed=err_embed("DM relay not enabled.")) + if d.get("frozen"): + return await ctx.send(embed=err_embed("Network is frozen.")) + if ctx.author.id in d.get("banned_users", []): + return await ctx.send(embed=err_embed("You're banned.")) + if ctx.author.id in d.get("muted_users", []): + return await ctx.send(embed=err_embed("You're muted.")) + + # Rate limit + bucket = self.cooldowns.get(name) + if bucket and bucket.is_rate_limited(ctx.author.id, name): + return await ctx.send(embed=warn_embed("Slow down!")) + + # Filter + if message: + matched = check_filters(message, d.get("word_filters", []), d.get("regex_filters", [])) + if matched: + return await ctx.send(embed=warn_embed("Message blocked by filter.")) + + # Build embed for channels + relay_em = build_dm_relay_embed(ctx.author, message, name) + + sent = 0 + for ch_id in d["channels"]: + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + relay_mode = self._get_override(d, ch_id, "relay_mode") or d.get("relay_mode", "webhook") + if relay_mode == "webhook": + wh = await self._wh(ch) + await wh.send( + content=message, + username=f"{ctx.author.display_name} (via DM)", + avatar_url=ctx.author.display_avatar.url, + wait=True, + ) + else: + await ch.send(embed=relay_em) + sent += 1 + except Exception: + pass + + # Also DM to other subscribers + for uid in d.get("dm_subscribers", []): + if uid == ctx.author.id: continue + u = self.bot.get_user(uid) + if u: + try: + em = build_dm_incoming_embed( + f"{ctx.author.display_name} (DM)", ctx.author.display_avatar.url, + "Direct Message", "DM", message, name, d.get("colour")) + await u.send(embed=em) + except Exception: pass + + await ctx.send(embed=ok_embed(f"Sent to {sent} channel(s) on **{name}**.")) + async with self.config.networks() as nets: + if name in nets: nets[name]["total_messages"] = nets[name].get("total_messages", 0) + 1 + await self._update_profile(name, ctx.author, 0) + + @wh_dm.command(name="list") + async def wh_dm_list(self, ctx): + """List your DM subscriptions.""" + nets = await self.config.networks() + subs = [n for n, d in nets.items() if ctx.author.id in d.get("dm_subscribers", [])] + if not subs: + return await ctx.send(embed=info_embed("You're not subscribed to any network DM relay.")) + await ctx.send(embed=info_embed("\n".join(f"• **{n}**" for n in subs), title="📧 DM Subscriptions")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # STAFF + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="staff", invoke_without_command=True) + async def wh_staff(self, ctx): await ctx.send_help(ctx.command) + + @wh_staff.command(name="add") + async def wh_staff_add(self, ctx, name: str, user: discord.User): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_owner(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return await ctx.send(embed=err_embed("Owner only.")) + if user.id in d.get("staff_ids", []): return await ctx.send(embed=err_embed("Already staff.")) + async with self.config.networks() as n: n[name].setdefault("staff_ids", []).append(user.id) + await ctx.send(embed=ok_embed(f"{user.mention} → staff on **{name}**.")) + await self._audit(name, "staff_add", ctx.author, target=str(user)) + + @wh_staff.command(name="remove") + async def wh_staff_rm(self, ctx, name: str, user: discord.User): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_owner(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return await ctx.send(embed=err_embed("Owner only.")) + async with self.config.networks() as n: + if user.id in n[name].get("staff_ids", []): n[name]["staff_ids"].remove(user.id) + await ctx.send(embed=ok_embed(f"{user.mention} removed from staff.")) + + @wh_staff.command(name="list") + async def wh_staff_ls(self, ctx, name: str): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + lines = [f"👑 **Owner:** {self.bot.get_user(d['owner_id']) or d['owner_id']}"] + for uid in d.get("staff_ids", []): + lines.append(f"⭐ {self.bot.get_user(uid) or uid}") + await ctx.send(embed=info_embed("\n".join(lines), title=f"Staff — {name}")) + + @wh.command(name="transfer") + async def wh_transfer(self, ctx, name: str, new_owner: discord.User): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_owner(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return await ctx.send(embed=err_embed("Owner only.")) + async with self.config.networks() as n: + n[name]["owner_id"] = new_owner.id + if new_owner.id in n[name].get("staff_ids", []): n[name]["staff_ids"].remove(new_owner.id) + await ctx.send(embed=ok_embed(f"Ownership → {new_owner.mention}.")) + await self._audit(name, "transfer", ctx.author, target=str(new_owner)) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # MODERATION + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="mod", invoke_without_command=True) + async def wh_mod(self, ctx): await ctx.send_help(ctx.command) + + @wh_mod.command(name="ban") + async def wh_mod_ban(self, ctx, name: str, user: discord.User): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return await ctx.send(embed=err_embed("Staff only.")) + async with self.config.networks() as n: + if user.id not in n[name].get("banned_users", []): n[name].setdefault("banned_users", []).append(user.id) + # Also remove from DM subs + if user.id in n[name].get("dm_subscribers", []): n[name]["dm_subscribers"].remove(user.id) + await ctx.send(embed=ok_embed(f"{user.mention} banned from **{name}**.")) + await self._audit(name, "ban", ctx.author, target=str(user)) + await self._log(d, warn_embed(f"{ctx.author} banned {user}.")) + + @wh_mod.command(name="unban") + async def wh_mod_unban(self, ctx, name: str, user: discord.User): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + if user.id in n[name].get("banned_users", []): n[name]["banned_users"].remove(user.id) + await ctx.send(embed=ok_embed(f"{user.mention} unbanned.")) + + @wh_mod.command(name="mute") + async def wh_mod_mute(self, ctx, name: str, user: discord.User): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + if user.id not in n[name].get("muted_users", []): n[name].setdefault("muted_users", []).append(user.id) + await ctx.send(embed=ok_embed(f"{user.mention} muted.")) + + @wh_mod.command(name="unmute") + async def wh_mod_unmute(self, ctx, name: str, user: discord.User): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + if user.id in n[name].get("muted_users", []): n[name]["muted_users"].remove(user.id) + await ctx.send(embed=ok_embed(f"{user.mention} unmuted.")) + + @wh_mod.command(name="ban-server") + async def wh_mod_bans(self, ctx, name: str, guild_id: int): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + rm = [] + async with self.config.networks() as n: + if guild_id not in n[name].get("banned_servers", []): n[name].setdefault("banned_servers", []).append(guild_id) + for ch_id in list(n[name]["channels"]): + ch = self.bot.get_channel(ch_id) + if ch and ch.guild.id == guild_id: n[name]["channels"].remove(ch_id); rm.append(ch_id) + await ctx.send(embed=ok_embed(f"Server banned, {len(rm)} channels removed.")) + + @wh_mod.command(name="unban-server") + async def wh_mod_unbans(self, ctx, name: str, guild_id: int): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + if guild_id in n[name].get("banned_servers", []): n[name]["banned_servers"].remove(guild_id) + await ctx.send(embed=ok_embed("Server unbanned.")) + + @wh_mod.command(name="mute-server") + async def wh_mod_ms(self, ctx, name: str, guild_id: int): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + if guild_id not in n[name].get("muted_servers", []): n[name].setdefault("muted_servers", []).append(guild_id) + await ctx.send(embed=ok_embed("Server muted.")) + + @wh_mod.command(name="unmute-server") + async def wh_mod_ums(self, ctx, name: str, guild_id: int): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + if guild_id in n[name].get("muted_servers", []): n[name]["muted_servers"].remove(guild_id) + await ctx.send(embed=ok_embed("Server unmuted.")) + + @wh_mod.command(name="allowlist-add") + async def wh_mod_aa(self, ctx, name: str, guild_id: int): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_owner(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + if guild_id not in n[name].get("allowlist_servers", []): n[name].setdefault("allowlist_servers", []).append(guild_id) + await ctx.send(embed=ok_embed("Added to allowlist.")) + + @wh_mod.command(name="allowlist-remove") + async def wh_mod_ar(self, ctx, name: str, guild_id: int): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_owner(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + if guild_id in n[name].get("allowlist_servers", []): n[name]["allowlist_servers"].remove(guild_id) + await ctx.send(embed=ok_embed("Removed from allowlist.")) + + @wh_mod.command(name="purge") + async def wh_mod_purge(self, ctx, name: str, count: int = 10): + """Delete last N relayed messages across all channels (max 50).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + count = min(50, max(1, count)) + deleted = 0 + for ch_id in d["channels"]: + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + msgs = [m async for m in ch.history(limit=count) if m.author == self.bot.user or (m.webhook_id is not None)] + for m in msgs[:count]: + try: await m.delete(); deleted += 1 + except Exception: pass + except Exception: pass + await ctx.send(embed=ok_embed(f"Purged {deleted} messages across network.")) + await self._audit(name, "purge", ctx.author, details=f"{count} requested, {deleted} deleted") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # FILTERS + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="filter", invoke_without_command=True) + async def wh_filter(self, ctx): await ctx.send_help(ctx.command) + + @wh_filter.command(name="add-word") + async def wh_f_aw(self, ctx, name: str, *, word: str): + name = name.lower(); d = await self._net(name) + if not d or (not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author)): return + async with self.config.networks() as n: n[name].setdefault("word_filters", []).append(word) + await ctx.send(embed=ok_embed("Word filter added.")) + + @wh_filter.command(name="remove-word") + async def wh_f_rw(self, ctx, name: str, *, word: str): + name = name.lower(); d = await self._net(name) + if not d or (not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author)): return + async with self.config.networks() as n: + if word in n[name].get("word_filters", []): n[name]["word_filters"].remove(word) + await ctx.send(embed=ok_embed("Word filter removed.")) + + @wh_filter.command(name="add-regex") + async def wh_f_ar(self, ctx, name: str, *, pattern: str): + try: re.compile(pattern) + except re.error as e: return await ctx.send(embed=err_embed(f"Invalid: {e}")) + name = name.lower(); d = await self._net(name) + if not d or (not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author)): return + async with self.config.networks() as n: n[name].setdefault("regex_filters", []).append(pattern) + await ctx.send(embed=ok_embed("Regex filter added.")) + + @wh_filter.command(name="remove-regex") + async def wh_f_rr(self, ctx, name: str, *, pattern: str): + name = name.lower(); d = await self._net(name) + if not d or (not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author)): return + async with self.config.networks() as n: + if pattern in n[name].get("regex_filters", []): n[name]["regex_filters"].remove(pattern) + await ctx.send(embed=ok_embed("Regex filter removed.")) + + @wh_filter.command(name="list") + async def wh_f_ls(self, ctx, name: str): + name = name.lower(); d = await self._net(name) + if not d: return + lines = [] + for w in d.get("word_filters", []): lines.append(f"Word: `{w}`") + for r in d.get("regex_filters", []): lines.append(f"Regex: `{r}`") + await ctx.send(embed=info_embed("\n".join(lines) if lines else "No filters.", title=f"Filters — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # AUTO-MODERATION + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="automod", invoke_without_command=True) + async def wh_am(self, ctx): await ctx.send_help(ctx.command) + + async def _am_toggle(self, ctx, name, key, val, extra=None): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + n[name].setdefault("automod", {})[key] = val + if extra: + for k, v in extra.items(): n[name]["automod"][k] = v + await ctx.send(embed=ok_embed(f"`{key}` → `{val}` for **{name}**.")) + + @wh_am.command(name="enable") + async def wh_am_on(self, ctx, name: str): await self._am_toggle(ctx, name, "enabled", True) + @wh_am.command(name="disable") + async def wh_am_off(self, ctx, name: str): await self._am_toggle(ctx, name, "enabled", False) + @wh_am.command(name="anti-spam") + async def wh_am_spam(self, ctx, name: str, toggle: bool): await self._am_toggle(ctx, name, "anti_spam", toggle) + @wh_am.command(name="anti-mentions") + async def wh_am_ment(self, ctx, name: str, toggle: bool, max_mentions: int = 5): + await self._am_toggle(ctx, name, "anti_mention_spam", toggle, {"max_mentions": max_mentions}) + @wh_am.command(name="anti-caps") + async def wh_am_caps(self, ctx, name: str, toggle: bool, threshold: float = 0.7): + await self._am_toggle(ctx, name, "anti_caps", toggle, {"caps_threshold": threshold}) + @wh_am.command(name="anti-invite") + async def wh_am_inv(self, ctx, name: str, toggle: bool): await self._am_toggle(ctx, name, "anti_invite", toggle) + @wh_am.command(name="anti-link") + async def wh_am_link(self, ctx, name: str, toggle: bool): await self._am_toggle(ctx, name, "anti_link", toggle) + @wh_am.command(name="anti-zalgo") + async def wh_am_zalgo(self, ctx, name: str, toggle: bool): + """Block zalgo/combining-character text.""" + await self._am_toggle(ctx, name, "anti_zalgo", toggle) + @wh_am.command(name="anti-spoiler") + async def wh_am_spoiler(self, ctx, name: str, toggle: bool): + """Block excessive spoiler tags.""" + await self._am_toggle(ctx, name, "anti_spoiler", toggle) + @wh_am.command(name="anti-emote-spam") + async def wh_am_emote(self, ctx, name: str, toggle: bool, max_emotes: int = 10): + """Block custom emote spam.""" + await self._am_toggle(ctx, name, "anti_emote_spam", toggle, {"max_emotes": max_emotes}) + @wh_am.command(name="anti-newlines") + async def wh_am_nl(self, ctx, name: str, toggle: bool, max_newlines: int = 15): + """Block newline spam.""" + await self._am_toggle(ctx, name, "anti_newline_spam", toggle, {"max_newlines": max_newlines}) + @wh_am.command(name="anti-raid") + async def wh_am_raid(self, ctx, name: str, toggle: bool, threshold: int = 10, window: float = 60): + """Auto-freeze network on raid detection.""" + await self._am_toggle(ctx, name, "anti_raid", toggle, {"raid_threshold": threshold, "raid_window": window}) + + @wh_am.command(name="status") + async def wh_am_status(self, ctx, name: str): + name = name.lower(); d = await self._net(name) + if not d: return + am = d.get("automod", {}) + lines = [] + for k in ("enabled", "anti_spam", "anti_mention_spam", "anti_caps", "anti_invite", "anti_link", "anti_zalgo", "anti_spoiler", "anti_emote_spam", "anti_newline_spam", "anti_raid"): + lines.append(f"{'✅' if am.get(k) else '❌'} `{k}`") + await ctx.send(embed=info_embed("\n".join(lines), title=f"Auto-mod — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # INVITES + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="invite", invoke_without_command=True) + async def wh_inv(self, ctx): await ctx.send_help(ctx.command) + + @wh_inv.command(name="create") + async def wh_inv_create(self, ctx, name: str, max_uses: int = 0, expires_minutes: int = 0): + """Create invite code. 0=unlimited/no expiry.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + code = generate_invite_code() + inv = {"uses": 0, "max_uses": max_uses or None, + "expires_at": datetime.fromtimestamp(time.time() + expires_minutes * 60, tz=timezone.utc).isoformat() if expires_minutes > 0 else None, + "creator_id": ctx.author.id} + async with self.config.networks() as n: n[name].setdefault("invites", {})[code] = inv + await ctx.send(embed=ok_embed(f"Invite for **{name}**:\n```\n{code}\n```\nJoin: `{ctx.clean_prefix}wh invite use {code}`", title="🔗 Invite")) + + @wh_inv.command(name="vanity") + async def wh_inv_vanity(self, ctx, name: str, *, word: str): + """Set a vanity invite word (e.g. `gaming` → `[p]wh invite use gaming`).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_owner(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + word = word.lower().strip().replace(" ", "-") + # Check uniqueness + for n, nd in (await self.config.networks()).items(): + if nd.get("vanity_invite") == word and n != name: + return await ctx.send(embed=err_embed(f"Vanity `{word}` is taken.")) + async with self.config.networks() as n: n[name]["vanity_invite"] = word + await ctx.send(embed=ok_embed(f"Vanity invite → `{word}`. Join: `{ctx.clean_prefix}wh invite use {word}`")) + + @wh_inv.command(name="use", aliases=["join"]) + @commands.guild_only() + async def wh_inv_use(self, ctx, code: str): + """Use an invite code or vanity name to join.""" + nets = await self.config.networks() + target = None + inv_data = None + is_vanity = False + + # Check vanity first + for n, d in nets.items(): + if d.get("vanity_invite") and d["vanity_invite"].lower() == code.lower(): + target = n; is_vanity = True; break + + # Then check codes + if not target: + for n, d in nets.items(): + if code in d.get("invites", {}): + target = n; inv_data = d["invites"][code]; break + + if not target: + return await ctx.send(embed=err_embed("Invalid invite.")) + + d = nets[target] + if inv_data: + if inv_data.get("expires_at"): + if datetime.now(timezone.utc) > datetime.fromisoformat(inv_data["expires_at"]): + async with self.config.networks() as n: n[target].get("invites", {}).pop(code, None) + return await ctx.send(embed=err_embed("Expired.")) + if inv_data.get("max_uses") and inv_data["uses"] >= inv_data["max_uses"]: + return await ctx.send(embed=err_embed("Max uses reached.")) + + # Standard checks + if d.get("allowlist_servers") and ctx.guild.id not in d["allowlist_servers"]: return await ctx.send(embed=err_embed("Not on allowlist.")) + if ctx.guild.id in d.get("banned_servers", []): return await ctx.send(embed=err_embed("Server banned.")) + if ctx.channel.id in d["channels"]: return await ctx.send(embed=err_embed("Already linked.")) + ex = await self._net_for_ch(ctx.channel.id) + if ex: return await ctx.send(embed=err_embed(f"Already in **{ex}**.")) + + async with self.config.networks() as n: + n[target]["channels"].append(ctx.channel.id) + if inv_data and not is_vanity: n[target]["invites"][code]["uses"] += 1 + d = n[target] + await ctx.send(embed=ok_embed(f"Linked to **{target}**! ({len(d['channels'])} channels)")) + await self._status(target, d, ctx.channel, f"📡 **{ctx.guild.name}** joined via invite.") + if d.get("welcome_message"): await ctx.send(embed=info_embed(d["welcome_message"], title=f"👋 Welcome!")) + if d.get("motd"): await ctx.send(embed=info_embed(d["motd"], title=f"📋 MOTD")) + + @wh_inv.command(name="revoke") + async def wh_inv_revoke(self, ctx, name: str, code: str): + name = name.lower(); d = await self._net(name) + if not d or (not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author)): return + async with self.config.networks() as n: n[name].get("invites", {}).pop(code, None) + await ctx.send(embed=ok_embed(f"Invite `{code}` revoked.")) + + @wh_inv.command(name="list") + async def wh_inv_list(self, ctx, name: str): + name = name.lower(); d = await self._net(name) + if not d: return + invs = d.get("invites", {}) + van = d.get("vanity_invite") + lines = [] + if van: lines.append(f"🌟 Vanity: `{van}`") + for c, i in invs.items(): + lines.append(f"`{c}` — {i['uses']}/{i.get('max_uses') or '∞'}") + await ctx.send(embed=info_embed("\n".join(lines) if lines else "No invites.", title=f"Invites — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # ANNOUNCEMENTS & PORTAL + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="announce") + async def wh_announce(self, ctx, name: str, *, message: str): + """Broadcast to all channels + DM subscribers.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + em = announce_embed(message, title=f"📢 {name}") + em.set_footer(text=f"From {ctx.author.display_name}") + sent = 0 + for ch_id in d["channels"]: + ch = self.bot.get_channel(ch_id) + if ch: + try: await ch.send(embed=em); sent += 1 + except: pass + # Also DM subscribers + dm_sent = 0 + for uid in d.get("dm_subscribers", []): + u = self.bot.get_user(uid) + if u: + try: await u.send(embed=em); dm_sent += 1 + except: pass + await ctx.send(embed=ok_embed(f"Sent to {sent} channels + {dm_sent} DM subscribers.")) + await self._audit(name, "announce", ctx.author) + + @wh.command(name="portal") + @commands.guild_only() + async def wh_portal(self, ctx, name: str = None): + """Create/refresh portal status embed.""" + if not name: + name = await self._net_for_ch(ctx.channel.id) + if not name: return await ctx.send(embed=err_embed("Not linked.")) + else: name = name.lower() + d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + embed = build_portal_embed(name, d, len(d["channels"]), d.get("total_messages", 0)) + existing = d.get("portal_messages", {}).get(str(ctx.channel.id)) + if existing: + try: + msg = await ctx.channel.fetch_message(existing) + await msg.edit(embed=embed) + return await ctx.send(embed=ok_embed("Portal refreshed!"), delete_after=5) + except: pass + msg = await ctx.send(embed=embed) + try: await msg.pin() + except: pass + async with self.config.networks() as n: n[name].setdefault("portal_messages", {})[str(ctx.channel.id)] = msg.id + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # STARBOARD + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="starboard", aliases=["star"], invoke_without_command=True) + async def wh_star(self, ctx): await ctx.send_help(ctx.command) + + @wh_star.command(name="enable") + async def wh_star_on(self, ctx, name: str, channel: discord.TextChannel, threshold: int = 3): + """Enable cross-network starboard.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + n[name]["starboard_enabled"] = True + n[name]["starboard_channel"] = channel.id + n[name]["starboard_threshold"] = threshold + await ctx.send(embed=ok_embed(f"Starboard → {channel.mention} (threshold: {threshold}⭐)")) + + @wh_star.command(name="disable") + async def wh_star_off(self, ctx, name: str): + await self._toggle(ctx, name, "starboard_enabled", False) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # KARMA / REPUTATION + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="karma", invoke_without_command=True) + async def wh_karma(self, ctx): await ctx.send_help(ctx.command) + + @wh_karma.command(name="enable") + async def wh_k_on(self, ctx, name: str, emoji: str = "👍"): + """Enable karma. React with the emoji on relayed messages.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + n[name]["karma_enabled"] = True + n[name]["karma_emoji"] = emoji + await ctx.send(embed=ok_embed(f"Karma enabled (react {emoji} to give karma).")) + + @wh_karma.command(name="disable") + async def wh_k_off(self, ctx, name: str): + await self._toggle(ctx, name, "karma_enabled", False) + + @wh_karma.command(name="check") + async def wh_k_check(self, ctx, name: str, user: discord.User = None): + """Check karma score.""" + name = name.lower(); d = await self._net(name) + if not d: return + user = user or ctx.author + score = d.get("karma_scores", {}).get(str(user.id), 0) + await ctx.send(embed=info_embed(f"**{user.display_name}** has **{score}** karma on **{name}**.", title="⭐ Karma")) + + @wh_karma.command(name="leaderboard", aliases=["lb"]) + async def wh_k_lb(self, ctx, name: str): + """Karma leaderboard.""" + name = name.lower(); d = await self._net(name) + if not d: return + scores = d.get("karma_scores", {}) + top = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:10] + medals = ["🥇", "🥈", "🥉"] + [f"**{i}.**" for i in range(4, 11)] + lines = [] + for i, (uid, sc) in enumerate(top): + u = self.bot.get_user(int(uid)) + lines.append(f"{medals[i]} {u or uid} — **{sc}** karma") + await ctx.send(embed=info_embed("\n".join(lines) if lines else "No karma yet.", title=f"Karma — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # HIGHLIGHTS + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="highlight", aliases=["hl"], invoke_without_command=True) + async def wh_hl(self, ctx): await ctx.send_help(ctx.command) + + @wh_hl.command(name="add") + async def wh_hl_add(self, ctx, name: str, *, keyword: str): + """Get DM'd when a keyword appears in the network.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + uid = str(ctx.author.id) + async with self.config.networks() as n: + hl = n[name].setdefault("highlights", {}).setdefault(uid, []) + if keyword.lower() not in [k.lower() for k in hl]: + hl.append(keyword) + await ctx.send(embed=ok_embed(f"Highlight `{keyword}` added for **{name}**.")) + + @wh_hl.command(name="remove") + async def wh_hl_rm(self, ctx, name: str, *, keyword: str): + name = name.lower() + async with self.config.networks() as n: + if name in n: + hl = n[name].get("highlights", {}).get(str(ctx.author.id), []) + n[name]["highlights"][str(ctx.author.id)] = [k for k in hl if k.lower() != keyword.lower()] + await ctx.send(embed=ok_embed(f"Highlight `{keyword}` removed.")) + + @wh_hl.command(name="list") + async def wh_hl_ls(self, ctx, name: str): + name = name.lower(); d = await self._net(name) + if not d: return + kws = d.get("highlights", {}).get(str(ctx.author.id), []) + await ctx.send(embed=info_embed("\n".join(f"• `{k}`" for k in kws) if kws else "No highlights.", title=f"Your Highlights — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # SCHEDULED MESSAGES + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="schedule") + async def wh_schedule(self, ctx, name: str, minutes: int, *, message: str): + """Schedule a message to be sent to the network in X minutes.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + send_at = datetime.fromtimestamp(time.time() + minutes * 60, tz=timezone.utc).isoformat() + async with self.config.networks() as n: + n[name].setdefault("scheduled_messages", []).append({ + "content": message, "send_at": send_at, "author_id": ctx.author.id + }) + await ctx.send(embed=ok_embed(f"Scheduled for {minutes} min from now.")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # AUDIT LOG + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="audit") + async def wh_audit(self, ctx, name: str, count: int = 20): + """View recent audit log entries.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + entries = d.get("audit_log", [])[-count:] + if not entries: return await ctx.send(embed=info_embed("No audit entries.")) + lines = [] + for e in reversed(entries): + ts = e.get("timestamp", "?")[:16] + lines.append(f"`{ts}` **{e.get('action')}** by {e.get('user')} {e.get('target', '')}") + await ctx.send(embed=info_embed("\n".join(lines[:25]), title=f"Audit — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # BLACKOUT + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="blackout", invoke_without_command=True) + async def wh_bo(self, ctx): await ctx.send_help(ctx.command) + + @wh_bo.command(name="add") + async def wh_bo_add(self, ctx, name: str, start_hour: int, end_hour: int, *, days: str = "0,1,2,3,4,5,6"): + """Add blackout: freeze during hours (UTC). Days: 0=Mon..6=Sun""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + try: day_list = [int(x.strip()) for x in days.split(",")] + except: return await ctx.send(embed=err_embed("Invalid days.")) + async with self.config.networks() as n: + n[name].setdefault("blackout_schedules", []).append({"start_hour": start_hour, "end_hour": end_hour, "days": day_list}) + await ctx.send(embed=ok_embed(f"Blackout {start_hour:02d}:00–{end_hour:02d}:00 UTC on {days}.")) + + @wh_bo.command(name="clear") + async def wh_bo_clear(self, ctx, name: str): + name = name.lower(); d = await self._net(name) + if not d: return + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name]["blackout_schedules"] = []; n[name]["frozen"] = False + await ctx.send(embed=ok_embed("Blackout cleared & unfrozen.")) + + @wh_bo.command(name="list") + async def wh_bo_ls(self, ctx, name: str): + name = name.lower(); d = await self._net(name) + if not d: return + ss = d.get("blackout_schedules", []) + dn = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"] + lines = [f"{s['start_hour']:02d}:00–{s['end_hour']:02d}:00 — {','.join(dn[x] for x in s.get('days',[]))}" for s in ss] + await ctx.send(embed=info_embed("\n".join(lines) if lines else "None.", title=f"Blackout — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # BACKUP & RESTORE + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="backup") + async def wh_backup(self, ctx, name: str): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_owner(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + data = {"name": name, "version": self.__version__, "exported": datetime.now(timezone.utc).isoformat(), "config": d} + f = discord.File(fp=io.BytesIO(json.dumps(data, indent=2, default=str).encode()), filename=f"wormhole-{name}.json") + await ctx.send(embed=ok_embed("Backup:"), file=f) + + @wh.command(name="restore") + async def wh_restore(self, ctx, new_name: str = None): + if not ctx.message.attachments: return await ctx.send(embed=err_embed("Attach a JSON file.")) + try: backup = json.loads((await ctx.message.attachments[0].read()).decode()) + except: return await ctx.send(embed=err_embed("Invalid JSON.")) + cfg = backup.get("config") + if not cfg: return await ctx.send(embed=err_embed("Missing config.")) + name = (new_name or backup.get("name", "restored")).lower() + if name in await self.config.networks(): return await ctx.send(embed=err_embed(f"**{name}** exists.")) + cfg["owner_id"] = ctx.author.id + cfg["channels"] = [] + cfg["portal_messages"] = {} + cfg["created_at"] = datetime.now(timezone.utc).isoformat() + await self._save(name, cfg) + self.cooldowns[name] = CooldownBucket(cfg.get("rate_limit_rate", 5), cfg.get("rate_limit_per", 10.0)) + self.dup_detectors[name] = DuplicateDetector() + self.raid_detectors[name] = RaidDetector() + await ctx.send(embed=ok_embed(f"**{name}** restored! Link channels with `{ctx.clean_prefix}wh open {name}`.")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # GLOBAL BLOCKLIST + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="global", invoke_without_command=True) + @checks.is_owner() + async def wh_gl(self, ctx): await ctx.send_help(ctx.command) + + @wh_gl.command(name="ban-user") + @checks.is_owner() + async def wh_gl_bu(self, ctx, user: discord.User): + async with self.config.global_banned_users() as bl: + if user.id not in bl: bl.append(user.id) + await ctx.send(embed=ok_embed(f"{user.mention} globally blocked.")) + + @wh_gl.command(name="unban-user") + @checks.is_owner() + async def wh_gl_uu(self, ctx, user: discord.User): + async with self.config.global_banned_users() as bl: + if user.id in bl: bl.remove(user.id) + await ctx.send(embed=ok_embed("Unblocked.")) + + @wh_gl.command(name="ban-server") + @checks.is_owner() + async def wh_gl_bs(self, ctx, gid: int): + async with self.config.global_banned_servers() as bl: + if gid not in bl: bl.append(gid) + await ctx.send(embed=ok_embed("Server globally blocked.")) + + @wh_gl.command(name="unban-server") + @checks.is_owner() + async def wh_gl_us(self, ctx, gid: int): + async with self.config.global_banned_servers() as bl: + if gid in bl: bl.remove(gid) + await ctx.send(embed=ok_embed("Unblocked.")) + + @wh_gl.command(name="list") + @checks.is_owner() + async def wh_gl_ls(self, ctx): + users = await self.config.global_banned_users() + servers = await self.config.global_banned_servers() + lines = [f"User: {self.bot.get_user(u) or u}" for u in users] + [f"Server: {self.bot.get_guild(g) or g}" for g in servers] + await ctx.send(embed=info_embed("\n".join(lines) if lines else "Empty.", title="Global Blocklist")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # PROFILES & STATS + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="profile") + async def wh_profile(self, ctx, name: str, user: discord.User = None): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + user = user or ctx.author + p = d.get("user_profiles", {}).get(str(user.id)) + if not p: return await ctx.send(embed=info_embed(f"{user.display_name} has no activity on **{name}**.")) + em = discord.Embed(title=f"👤 {user.display_name} — {name}", colour=COLOUR_INFO) + em.set_thumbnail(url=user.display_avatar.url) + em.add_field(name="Messages", value=f"{p.get('messages',0):,}", inline=True) + em.add_field(name="First seen", value=p.get("first_seen","?")[:10], inline=True) + em.add_field(name="Servers", value=str(len(p.get("servers",[]))), inline=True) + karma = d.get("karma_scores", {}).get(str(user.id), 0) + if d.get("karma_enabled"): em.add_field(name="Karma", value=str(karma), inline=True) + status = [] + if user.id == d["owner_id"]: status.append("👑 Owner") + elif user.id in d.get("staff_ids", []): status.append("⭐ Staff") + if user.id in d.get("banned_users", []): status.append("🚫 Banned") + if user.id in d.get("muted_users", []): status.append("🔇 Muted") + if user.id in d.get("dm_subscribers", []): status.append("📧 DM Sub") + if status: em.add_field(name="Status", value=" ".join(status), inline=False) + await ctx.send(embed=em) + + @wh.command(name="stats") + async def wh_stats(self, ctx, name: str): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + profiles = d.get("user_profiles", {}) + top = sorted(profiles.items(), key=lambda x: x[1].get("messages", 0), reverse=True)[:5] + em = discord.Embed(title=f"📊 {name}", colour=COLOUR_INFO) + em.add_field(name="Messages", value=f"{d.get('total_messages',0):,}", inline=True) + em.add_field(name="Channels", value=str(len(d["channels"])), inline=True) + em.add_field(name="Users", value=str(len(profiles)), inline=True) + em.add_field(name="DM subs", value=str(len(d.get("dm_subscribers",[]))), inline=True) + em.add_field(name="Mode", value=f"`{d.get('relay_mode','webhook')}`", inline=True) + em.add_field(name="Created", value=d.get("created_at","?")[:10], inline=True) + if top: + lb = [] + for i, (uid, pr) in enumerate(top): + u = self.bot.get_user(int(uid)) + lb.append(f"{'🥇🥈🥉'[i] if i < 3 else f'{i+1}.'} {u or uid} — {pr.get('messages',0):,}") + em.add_field(name="🏆 Top", value="\n".join(lb), inline=False) + await ctx.send(embed=em) + + @wh.command(name="rules") + async def wh_rules(self, ctx, name: str): + """Display network rules.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + rules = d.get("rules", "") + if not rules: return await ctx.send(embed=info_embed("No rules set.")) + await ctx.send(embed=info_embed(rules, title=f"📜 Rules — {name}")) + + @wh.command(name="motd") + async def wh_motd(self, ctx, name: str): + """Display the message of the day.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + motd = d.get("motd", "") + if not motd: return await ctx.send(embed=info_embed("No MOTD set.")) + await ctx.send(embed=info_embed(motd, title=f"📋 MOTD — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # SEARCH + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="search") + async def wh_search(self, ctx, name: str, *, query: str): + """Search recent messages across the network.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + results = [] + for ch_id in d["channels"][:10]: # Limit to 10 channels + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + async for msg in ch.history(limit=100): + if query.lower() in (msg.content or "").lower(): + results.append(f"**{msg.author.display_name}** in #{ch.name}: {truncate(msg.content, 80)}") + if len(results) >= 15: break + except: pass + if len(results) >= 15: break + await ctx.send(embed=info_embed("\n".join(results) if results else "No results.", title=f"🔍 Search — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # ANONYMOUS MODE + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh_set.command(name="anonymous", aliases=["anon"]) + async def wh_set_anon(self, ctx, name: str, toggle: bool): + """Toggle anonymous mode — hides real usernames.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + n[name]["anonymous"] = toggle + if toggle and not n[name].get("anon_salt"): + n[name]["anon_salt"] = hashlib.sha256(str(time.time()).encode()).hexdigest()[:16] + msg = "enabled — all usernames will be hidden" if toggle else "disabled" + await ctx.send(embed=ok_embed(f"Anonymous mode {msg} for **{name}**.")) + await self._audit(name, "set anonymous", ctx.author, details=str(toggle)) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # MIRROR CHANNELS (one-way / receive-only) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="mirror", invoke_without_command=True) + async def wh_mirror(self, ctx): + """Manage one-way mirror channels (receive-only).""" + await ctx.send_help(ctx.command) + + @wh_mirror.command(name="add") + @commands.guild_only() + async def wh_mirror_add(self, ctx, name: str): + """Make this channel receive-only (messages here won't relay out).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + if ctx.channel.id not in d.get("channels", []): + return await ctx.send(embed=err_embed("Channel not linked to this network.")) + async with self.config.networks() as n: + mirrors = n[name].setdefault("mirror_channels", []) + if ctx.channel.id not in mirrors: + mirrors.append(ctx.channel.id) + await ctx.send(embed=ok_embed(f"This channel is now *receive-only* on **{name}**. Messages sent here won't relay out.")) + + @wh_mirror.command(name="remove") + @commands.guild_only() + async def wh_mirror_rm(self, ctx, name: str): + """Remove mirror (receive-only) status from this channel.""" + name = name.lower() + async with self.config.networks() as n: + if name in n: + mirrors = n[name].get("mirror_channels", []) + if ctx.channel.id in mirrors: + mirrors.remove(ctx.channel.id) + await ctx.send(embed=ok_embed("This channel is now two-way again.")) + + @wh_mirror.command(name="list") + async def wh_mirror_ls(self, ctx, name: str): + """List mirror channels.""" + name = name.lower(); d = await self._net(name) + if not d: return + mirrors = d.get("mirror_channels", []) + if not mirrors: + return await ctx.send(embed=info_embed("No mirror channels.", title=f"Mirror — {name}")) + lines = [] + for cid in mirrors: + ch = self.bot.get_channel(cid) + lines.append(f"• {ch.guild.name} › #{ch.name}" if ch else f"• `{cid}`") + await ctx.send(embed=info_embed("\n".join(lines), title=f"Mirror (receive-only) — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # POLLS + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="poll", invoke_without_command=True) + async def wh_poll(self, ctx): + """Network-wide polls.""" + await ctx.send_help(ctx.command) + + @wh_poll.command(name="create") + async def wh_poll_create(self, ctx, name: str, minutes: int, question: str, *, options: str): + """Create a poll. Options separated by |. Ex: `[p]wh poll create net 60 "Favourite?" Option A | Option B | Option C`""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + opts = [o.strip() for o in options.split("|") if o.strip()] + if len(opts) < 2 or len(opts) > 10: + return await ctx.send(embed=err_embed("2–10 options required, separated by `|`.")) + poll_id = hashlib.sha256(f"{name}{time.time()}{random.random()}".encode()).hexdigest()[:8] + emojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"] + expires = datetime.fromtimestamp(time.time() + minutes * 60, tz=timezone.utc).isoformat() if minutes > 0 else None + poll_data = { + "question": question, "options": opts, + "votes": {str(i): [] for i in range(len(opts))}, + "author": ctx.author.id, "created": datetime.now(timezone.utc).isoformat(), + "expires": expires, "msg_map": {}, + } + # Build embed + lines = [f"{emojis[i]} **{opt}**" for i, opt in enumerate(opts)] + desc = f"**{question}**\n\n" + "\n".join(lines) + f"\n\nReact to vote! Poll ID: `{poll_id}`" + if expires: + desc += f"\nExpires in {minutes} minutes." + em = info_embed(desc, title=f"📊 Poll — {name}") + em.set_footer(text=f"Created by {ctx.author.display_name}") + # Send to all channels + msg_map = {} + for ch_id in d["channels"]: + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + msg = await ch.send(embed=em) + for i in range(len(opts)): + await msg.add_reaction(emojis[i]) + msg_map[str(ch_id)] = msg.id + except: pass + poll_data["msg_map"] = msg_map + async with self.config.networks() as n: + n[name].setdefault("active_polls", {})[poll_id] = poll_data + await ctx.send(embed=ok_embed(f"Poll `{poll_id}` created in {len(msg_map)} channels!")) + + @wh_poll.command(name="close") + async def wh_poll_close(self, ctx, name: str, poll_id: str): + """Close a poll and show results.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + polls = d.get("active_polls", {}) + if poll_id not in polls: + return await ctx.send(embed=err_embed(f"Poll `{poll_id}` not found.")) + poll = polls[poll_id] + # Tally votes from reactions + emojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"] + votes = {str(i): set() for i in range(len(poll["options"]))} + for ch_id_str, msg_id in poll.get("msg_map", {}).items(): + ch = self.bot.get_channel(int(ch_id_str)) + if not ch: continue + try: + msg = await ch.fetch_message(msg_id) + for i, react in enumerate(msg.reactions): + if str(react.emoji) in emojis[:len(poll["options"])]: + idx = emojis.index(str(react.emoji)) + async for user in react.users(): + if not user.bot: + votes[str(idx)].add(user.id) + except: pass + # Convert sets to lists for storage/display + final_votes = {k: list(v) for k, v in votes.items()} + poll["votes"] = final_votes + results = self._format_poll_results(poll) + em = info_embed(results, title=f"📊 Poll Closed — {poll['question']}") + for ch_id in d["channels"]: + ch = self.bot.get_channel(ch_id) + if ch: + try: await ch.send(embed=em) + except: pass + async with self.config.networks() as n: + n[name].get("active_polls", {}).pop(poll_id, None) + await ctx.send(embed=ok_embed("Poll closed and results posted.")) + + @wh_poll.command(name="list") + async def wh_poll_ls(self, ctx, name: str): + """List active polls.""" + name = name.lower(); d = await self._net(name) + if not d: return + polls = d.get("active_polls", {}) + if not polls: + return await ctx.send(embed=info_embed("No active polls.", title=f"Polls — {name}")) + lines = [] + for pid, p in polls.items(): + exp = f" (expires {p['expires'][:16]})" if p.get("expires") else " (no expiry)" + lines.append(f"`{pid}` — {p['question']}{exp}") + await ctx.send(embed=info_embed("\n".join(lines), title=f"Active Polls — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # AFK SYSTEM + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="afk") + async def wh_afk(self, ctx, name: str, *, reason: str = "AFK"): + """Set yourself as AFK. Auto-cleared when you send a message.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + async with self.config.networks() as n: + n[name].setdefault("afk_users", {})[str(ctx.author.id)] = { + "reason": reason[:200], + "since": datetime.now(timezone.utc).isoformat(), + } + await ctx.send(embed=ok_embed(f"You're now AFK: *{reason}*\nSend any message in a linked channel to clear.")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # PERSONAL IGNORE LIST + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="ignore", invoke_without_command=True) + async def wh_ignore(self, ctx): + """Manage your personal ignore list (DM relay only).""" + await ctx.send_help(ctx.command) + + @wh_ignore.command(name="add") + async def wh_ignore_add(self, ctx, name: str, user: discord.User): + """Ignore a user — their messages won't appear in your DM relay.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + uid = str(ctx.author.id) + async with self.config.networks() as n: + ignores = n[name].setdefault("user_ignores", {}).setdefault(uid, []) + if user.id not in ignores: + ignores.append(user.id) + await ctx.send(embed=ok_embed(f"Ignoring **{user.display_name}** on **{name}**.")) + + @wh_ignore.command(name="remove") + async def wh_ignore_rm(self, ctx, name: str, user: discord.User): + """Stop ignoring a user.""" + name = name.lower() + uid = str(ctx.author.id) + async with self.config.networks() as n: + if name in n: + ignores = n[name].get("user_ignores", {}).get(uid, []) + if user.id in ignores: + ignores.remove(user.id) + await ctx.send(embed=ok_embed(f"No longer ignoring **{user.display_name}**.")) + + @wh_ignore.command(name="list") + async def wh_ignore_ls(self, ctx, name: str): + """Show your ignore list.""" + name = name.lower(); d = await self._net(name) + if not d: return + ignores = d.get("user_ignores", {}).get(str(ctx.author.id), []) + if not ignores: + return await ctx.send(embed=info_embed("Empty.", title="Ignore List")) + lines = [f"• {self.bot.get_user(uid) or uid}" for uid in ignores] + await ctx.send(embed=info_embed("\n".join(lines), title=f"Your Ignores — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # AUTO-RESPONSES + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="autoreply", aliases=["ar"], invoke_without_command=True) + async def wh_ar(self, ctx): + """Manage auto-responses (staff).""" + await ctx.send_help(ctx.command) + + @wh_ar.command(name="add") + async def wh_ar_add(self, ctx, name: str, trigger: str, *, reply: str): + """Add an auto-response. Use {user} and {server} in the reply.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + n[name].setdefault("auto_responses", {})[trigger] = { + "reply": reply[:500], "regex": False, "cooldown": 30, "last_used": 0, + } + await ctx.send(embed=ok_embed(f"Auto-response: `{trigger}` → {reply[:100]}")) + + @wh_ar.command(name="add-regex") + async def wh_ar_add_rx(self, ctx, name: str, pattern: str, *, reply: str): + """Add a regex auto-response.""" + try: re.compile(pattern) + except re.error as e: return await ctx.send(embed=err_embed(f"Invalid regex: {e}")) + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + n[name].setdefault("auto_responses", {})[pattern] = { + "reply": reply[:500], "regex": True, "cooldown": 30, "last_used": 0, + } + await ctx.send(embed=ok_embed(f"Regex auto-response: `{pattern}` → {reply[:100]}")) + + @wh_ar.command(name="remove") + async def wh_ar_rm(self, ctx, name: str, *, trigger: str): + """Remove an auto-response.""" + name = name.lower(); d = await self._net(name) + if not d: return + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + n[name].get("auto_responses", {}).pop(trigger, None) + await ctx.send(embed=ok_embed(f"Auto-response `{trigger}` removed.")) + + @wh_ar.command(name="cooldown") + async def wh_ar_cd(self, ctx, name: str, trigger: str, seconds: int): + """Set cooldown between auto-response triggers.""" + name = name.lower() + async with self.config.networks() as n: + if name in n and trigger in n[name].get("auto_responses", {}): + n[name]["auto_responses"][trigger]["cooldown"] = max(5, seconds) + await ctx.send(embed=ok_embed(f"Cooldown → {seconds}s.")) + + @wh_ar.command(name="list") + async def wh_ar_ls(self, ctx, name: str): + """List auto-responses.""" + name = name.lower(); d = await self._net(name) + if not d: return + ar = d.get("auto_responses", {}) + if not ar: + return await ctx.send(embed=info_embed("None.", title=f"Auto-Responses — {name}")) + lines = [] + for trigger, cfg in ar.items(): + typ = "regex" if cfg.get("regex") else "word" + lines.append(f"`{trigger}` ({typ}) → {truncate(cfg.get('reply', ''), 60)} [cd: {cfg.get('cooldown', 30)}s]") + await ctx.send(embed=info_embed("\n".join(lines), title=f"Auto-Responses — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # EPHEMERAL MESSAGES + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh_set.command(name="ephemeral") + async def wh_set_ephemeral(self, ctx, name: str, seconds: int): + """Auto-delete relayed messages after N seconds (0=disabled, max 3600).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + val = min(3600, max(0, seconds)) + async with self.config.networks() as n: n[name]["ephemeral_delay"] = val + msg = f"Relayed messages will auto-delete after {val}s." if val else "Ephemeral mode disabled." + await ctx.send(embed=ok_embed(msg)) + + @wh_set.command(name="media-only") + async def wh_set_media(self, ctx, name: str, toggle: bool): + """Only relay messages that have attachments/images.""" + await self._toggle(ctx, name, "media_only", toggle) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # BOOKMARKS + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="bookmark", aliases=["bm"], invoke_without_command=True) + async def wh_bm(self, ctx): + """Save and view message bookmarks.""" + await ctx.send_help(ctx.command) + + @wh_bm.command(name="save") + async def wh_bm_save(self, ctx, message_id: int = None): + """Bookmark a message. Reply to a message or provide ID.""" + target = None + if ctx.message.reference and ctx.message.reference.message_id: + try: + target = await ctx.channel.fetch_message(ctx.message.reference.message_id) + except: pass + elif message_id: + try: + target = await ctx.channel.fetch_message(message_id) + except: pass + if not target: + return await ctx.send(embed=err_embed("Reply to a message or provide a message ID.")) + bm = { + "content": truncate(target.content, 500), + "author": str(target.author), + "server": target.guild.name if target.guild else "DM", + "channel": target.channel.name, + "timestamp": target.created_at.isoformat(), + "jump_url": target.jump_url, + } + uid = str(ctx.author.id) + async with self.config.bookmarks() as bookmarks: + bookmarks.setdefault(uid, []).append(bm) + if len(bookmarks[uid]) > 50: + bookmarks[uid] = bookmarks[uid][-50:] + await ctx.send(embed=ok_embed("Bookmarked! View with `[p]wh bookmark list`.")) + # Also DM the bookmark + try: + em = info_embed( + f"**{bm['author']}** in #{bm['channel']} ({bm['server']}):\n\n> {bm['content']}\n\n[Jump]({bm['jump_url']})", + title="🔖 Bookmark Saved" + ) + await ctx.author.send(embed=em) + except: pass + + @wh_bm.command(name="list") + async def wh_bm_ls(self, ctx): + """View your bookmarks.""" + bookmarks = await self.config.bookmarks() + bms = bookmarks.get(str(ctx.author.id), []) + if not bms: + return await ctx.send(embed=info_embed("No bookmarks.", title="🔖 Bookmarks")) + lines = [] + for i, bm in enumerate(reversed(bms[-15:]), 1): + ts = bm.get("timestamp", "?")[:10] + lines.append(f"**{i}.** {bm['author']} — {truncate(bm['content'], 60)}\n *{bm['server']} › #{bm['channel']} • {ts}*") + await ctx.send(embed=info_embed("\n".join(lines), title="🔖 Your Bookmarks")) + + @wh_bm.command(name="clear") + async def wh_bm_clear(self, ctx): + """Clear all your bookmarks.""" + async with self.config.bookmarks() as bookmarks: + bookmarks.pop(str(ctx.author.id), None) + await ctx.send(embed=ok_embed("Bookmarks cleared.")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # USER COLOURS + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="colour", aliases=["color", "mycolour", "mycolor"]) + async def wh_user_colour(self, ctx, name: str, hex_colour: str): + """Set your personal embed colour in a network (#hex).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + try: + val = int(hex_colour.strip("#"), 16) + except ValueError: + return await ctx.send(embed=err_embed("Invalid hex colour. Use format `#FF5733`.")) + async with self.config.networks() as n: + n[name].setdefault("user_colours", {})[str(ctx.author.id)] = val + c = discord.Colour(val) + await ctx.send(embed=discord.Embed(description=f"Your colour is now **#{hex_colour.strip('#').upper()}** on **{name}**.", colour=c)) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # QUIET HOURS + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="quiet") + async def wh_quiet(self, ctx, name: str, start_hour: int, end_hour: int, utc_offset: int = 0): + """Set quiet hours — DM relay paused during these hours. Ex: `[p]wh quiet net 23 7 -5`""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not (0 <= start_hour <= 23 and 0 <= end_hour <= 23): + return await ctx.send(embed=err_embed("Hours must be 0–23.")) + async with self.config.networks() as n: + n[name].setdefault("quiet_hours", {})[str(ctx.author.id)] = { + "start_hour": start_hour, "end_hour": end_hour, "tz_offset": utc_offset, + } + await ctx.send(embed=ok_embed(f"Quiet hours: {start_hour:02d}:00–{end_hour:02d}:00 (UTC{utc_offset:+d}). DMs paused during this time.")) + + @wh.command(name="quiet-off") + async def wh_quiet_off(self, ctx, name: str): + """Disable quiet hours.""" + name = name.lower() + async with self.config.networks() as n: + if name in n: + n[name].get("quiet_hours", {}).pop(str(ctx.author.id), None) + await ctx.send(embed=ok_embed("Quiet hours disabled.")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # NETWORK ANALYTICS + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="analytics") + async def wh_analytics(self, ctx, name: str): + """View network analytics — activity breakdown, top users, peak hours.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + analytics = d.get("analytics", {}) + hourly = analytics.get("hourly", {}) + top_users = analytics.get("top_users", {}) + + em = discord.Embed(title=f"📈 Analytics — {name}", colour=COLOUR_INFO) + em.add_field(name="Total Messages", value=f"{d.get('total_messages', 0):,}", inline=True) + em.add_field(name="Channels", value=str(len(d.get("channels", []))), inline=True) + em.add_field(name="DM Subscribers", value=str(len(d.get("dm_subscribers", []))), inline=True) + + # Today's messages + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + today_count = sum(v for k, v in hourly.items() if k.startswith(today)) + em.add_field(name="Today", value=f"{today_count:,}", inline=True) + + # Last 7 days + week_count = sum(hourly.values()) + em.add_field(name="Last 7 Days", value=f"{week_count:,}", inline=True) + + # Peak hour + if hourly: + hour_totals: Dict[int, int] = defaultdict(int) + for key, count in hourly.items(): + try: + hour = int(key.split("-")[-1]) + hour_totals[hour] += count + except: pass + if hour_totals: + peak = max(hour_totals, key=hour_totals.get) + em.add_field(name="Peak Hour (UTC)", value=f"{peak:02d}:00", inline=True) + + # Activity chart (last 24 hours) + if hourly: + now = datetime.now(timezone.utc) + chart_lines = [] + for h_offset in range(23, -1, -1): + ts = now - timedelta(hours=h_offset) + key = ts.strftime("%Y-%m-%d-%H") + count = hourly.get(key, 0) + bar = "█" * min(count, 30) if count else "" + if count or h_offset % 6 == 0: + chart_lines.append(f"`{ts.strftime('%H')}` {bar} {count}") + if chart_lines: + em.add_field(name="Last 24h Activity", value="\n".join(chart_lines[-12:]), inline=False) + + # Top users + if top_users: + top5 = sorted(top_users.items(), key=lambda x: x[1], reverse=True)[:5] + medals = ["🥇", "🥈", "🥉", "4.", "5."] + ulines = [] + for i, (uid, count) in enumerate(top5): + u = self.bot.get_user(int(uid)) + ulines.append(f"{medals[i]} {u.display_name if u else uid} — **{count:,}**") + em.add_field(name="Top Posters", value="\n".join(ulines), inline=False) + + # Health status + unhealthy = d.get("unhealthy_channels", []) + if unhealthy: + em.add_field(name="⚠️ Unhealthy Channels", value=str(len(unhealthy)), inline=True) + last_hc = d.get("last_health_check") + if last_hc: + em.set_footer(text=f"Last health check: {last_hc[:16]}") + + await ctx.send(embed=em) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # HEALTH + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="health") + async def wh_health(self, ctx, name: str): + """Check relay health for all channels in a network.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + lines = [] + for ch_id in d.get("channels", []): + ch = self.bot.get_channel(ch_id) + if not ch: + lines.append(f"❌ `{ch_id}` — channel not visible") + continue + perms = ch.permissions_for(ch.guild.me) + issues = [] + if not perms.send_messages: issues.append("no send") + if not perms.manage_webhooks: issues.append("no webhooks") + if not perms.read_messages: issues.append("no read") + if not perms.embed_links: issues.append("no embeds") + if not perms.attach_files: issues.append("no files") + status = "✅" if not issues else "⚠️" + label = f"{ch.guild.name} › #{ch.name}" + issue_text = f" — {', '.join(issues)}" if issues else "" + is_mirror = " 📥" if ch_id in d.get("mirror_channels", []) else "" + lines.append(f"{status} {label}{is_mirror}{issue_text}") + await ctx.send(embed=info_embed("\n".join(lines) if lines else "No channels.", title=f"🏥 Health — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # NETWORK BRIDGING + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="bridge", invoke_without_command=True) + async def wh_bridge(self, ctx): + """Bridge networks together (one-way or two-way).""" + await ctx.send_help(ctx.command) + + @wh_bridge.command(name="add") + async def wh_bridge_add(self, ctx, source: str, target: str, two_way: bool = False): + """Bridge messages from source network into target network's channels.""" + source = source.lower(); target = target.lower() + sd = await self._net(source); td = await self._net(target) + if not sd: return await ctx.send(embed=err_embed(f"Source **{source}** not found.")) + if not td: return await ctx.send(embed=err_embed(f"Target **{target}** not found.")) + # Must be staff/owner on both + if not (await self._is_staff(sd, ctx.author.id) or await self.bot.is_owner(ctx.author)): + return await ctx.send(embed=err_embed(f"You must be staff on **{source}**.")) + if not (await self._is_staff(td, ctx.author.id) or await self.bot.is_owner(ctx.author)): + return await ctx.send(embed=err_embed(f"You must be staff on **{target}**.")) + async with self.config.networks() as n: + bt = n[source].setdefault("bridge_to", []) + if target not in bt: bt.append(target) + bf = n[target].setdefault("bridge_from", []) + if source not in bf: bf.append(source) + if two_way: + bt2 = n[target].setdefault("bridge_to", []) + if source not in bt2: bt2.append(source) + bf2 = n[source].setdefault("bridge_from", []) + if target not in bf2: bf2.append(target) + mode = "two-way" if two_way else "one-way" + await ctx.send(embed=ok_embed(f"Bridge ({mode}): **{source}** → **{target}**")) + await self._audit(source, "bridge_add", ctx.author, target=target, details=mode) + + @wh_bridge.command(name="remove") + async def wh_bridge_rm(self, ctx, source: str, target: str): + """Remove a bridge.""" + source = source.lower(); target = target.lower() + async with self.config.networks() as n: + if source in n: + bt = n[source].get("bridge_to", []) + if target in bt: bt.remove(target) + if target in n: + bf = n[target].get("bridge_from", []) + if source in bf: bf.remove(source) + await ctx.send(embed=ok_embed(f"Bridge **{source}** → **{target}** removed.")) + + @wh_bridge.command(name="list") + async def wh_bridge_ls(self, ctx, name: str): + """List bridges for a network.""" + name = name.lower(); d = await self._net(name) + if not d: return + bt = d.get("bridge_to", []) + bf = d.get("bridge_from", []) + lines = [] + for t in bt: lines.append(f"→ **{t}** (outgoing)") + for f in bf: lines.append(f"← **{f}** (incoming)") + await ctx.send(embed=info_embed("\n".join(lines) if lines else "No bridges.", title=f"Bridges — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # PHASE 5 — MENTION POLICY + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="mentions", invoke_without_command=True) + async def wh_mentions(self, ctx): + """Granular mention/ping controls (per-network, per-server, per-user).""" + await ctx.send_help(ctx.command) + + @wh_mentions.command(name="set") + async def wh_mentions_set(self, ctx, name: str, mention_type: str, toggle: bool): + """Set network-wide mention policy. Types: users, roles, everyone, here.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + key_map = {"users": "allow_user_mentions", "roles": "allow_role_mentions", + "everyone": "allow_everyone", "here": "allow_here"} + if mention_type not in key_map: + return await ctx.send(embed=err_embed(f"Invalid type. Choose: {', '.join(key_map.keys())}")) + async with self.config.networks() as n: + n[name].setdefault("mention_policy", {})[key_map[mention_type]] = toggle + state = "allowed" if toggle else "blocked" + await ctx.send(embed=ok_embed(f"**@{mention_type}** mentions are now *{state}* on **{name}**.")) + await self._audit(name, "mention_policy", ctx.author, details=f"{mention_type}={toggle}") + + @wh_mentions.command(name="server-set") + @commands.guild_only() + async def wh_mentions_server(self, ctx, name: str, mention_type: str, toggle: bool): + """Override mention policy for THIS server only. Types: users, roles, everyone, here.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + key_map = {"users": "allow_user_mentions", "roles": "allow_role_mentions", + "everyone": "allow_everyone", "here": "allow_here"} + if mention_type not in key_map: + return await ctx.send(embed=err_embed(f"Invalid type. Choose: {', '.join(key_map.keys())}")) + gid = str(ctx.guild.id) + async with self.config.networks() as n: + overrides = n[name].setdefault("server_mention_overrides", {}).setdefault(gid, {}) + overrides[key_map[mention_type]] = toggle + state = "allowed" if toggle else "blocked" + await ctx.send(embed=ok_embed(f"**@{mention_type}** mentions *{state}* for this server on **{name}**.")) + + @wh_mentions.command(name="exempt") + async def wh_mentions_exempt(self, ctx, name: str, user: discord.User): + """Allow a user to bypass mention restrictions.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + exempt = n[name].setdefault("mention_exempt_users", []) + if user.id not in exempt: + exempt.append(user.id) + await ctx.send(embed=ok_embed(f"**{user.display_name}** can now bypass mention policy on **{name}**.")) + + @wh_mentions.command(name="unexempt") + async def wh_mentions_unexempt(self, ctx, name: str, user: discord.User): + """Remove a user's mention exemption.""" + name = name.lower() + async with self.config.networks() as n: + if name in n: + exempt = n[name].get("mention_exempt_users", []) + if user.id in exempt: + exempt.remove(user.id) + await ctx.send(embed=ok_embed(f"**{user.display_name}** must follow mention policy now.")) + + @wh_mentions.command(name="status") + async def wh_mentions_status(self, ctx, name: str): + """View the current mention policy for a network.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + mp = d.get("mention_policy", {}) + sym = lambda v: "✅" if v else "❌" + lines = [ + f"**@user mentions:** {sym(mp.get('allow_user_mentions', False))}", + f"**@role mentions:** {sym(mp.get('allow_role_mentions', False))}", + f"**@everyone:** {sym(mp.get('allow_everyone', False))}", + f"**@here:** {sym(mp.get('allow_here', False))}", + ] + exempt = d.get("mention_exempt_users", []) + if exempt: + names = [str(self.bot.get_user(uid) or uid) for uid in exempt[:10]] + lines.append(f"\n**Exempt users (bypass policy):** {', '.join(names)}") + optouts = d.get("mention_optout_users", []) + if optouts: + names = [str(self.bot.get_user(uid) or uid) for uid in optouts[:10]] + extra = f" (+{len(optouts)-10} more)" if len(optouts) > 10 else "" + lines.append(f"\n**Opted out of pings ({len(optouts)}):** {', '.join(names)}{extra}") + overrides = d.get("server_mention_overrides", {}) + if overrides: + lines.append(f"\n**Server overrides:** {len(overrides)} server(s)") + for gid_str, ov in list(overrides.items())[:5]: + g = self.bot.get_guild(int(gid_str)) + gname = g.name if g else gid_str + ov_lines = ", ".join(f"{k.replace('allow_', '')}={sym(v)}" for k, v in ov.items()) + lines.append(f" • {gname}: {ov_lines}") + await ctx.send(embed=info_embed("\n".join(lines), title=f"Mention Policy — {name}")) + + @wh_mentions.command(name="optout") + async def wh_mentions_optout(self, ctx, name: str): + """Opt out of being @pinged through this network's relay. + + Your mentions will be stripped from relayed messages so you won't + receive cross-server pings. Use `wh mentions optin` to reverse. + """ + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Network not found.")) + uid = ctx.author.id + async with self.config.networks() as n: + optouts = n[name].setdefault("mention_optout_users", []) + if uid in optouts: + return await ctx.send(embed=info_embed("You're already opted out of pings on this network.")) + optouts.append(uid) + await ctx.send(embed=ok_embed( + f"**{ctx.author.display_name}**, you've opted out of relay pings on **{name}**.\n" + "Your @mentions will be stripped from relayed messages. Use `wh mentions optin` to reverse." + )) + + @wh_mentions.command(name="optin") + async def wh_mentions_optin(self, ctx, name: str): + """Opt back in to receiving @pings through this network's relay. + + Only works if user mentions are enabled for the network. + """ + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Network not found.")) + uid = ctx.author.id + async with self.config.networks() as n: + optouts = n[name].setdefault("mention_optout_users", []) + if uid not in optouts: + return await ctx.send(embed=info_embed("You're already opted in to pings on this network.")) + optouts.remove(uid) + await ctx.send(embed=ok_embed( + f"**{ctx.author.display_name}**, you've opted back in to relay pings on **{name}**." + )) + + @wh_mentions.command(name="mystatus") + async def wh_mentions_mystatus(self, ctx, name: str): + """Check your personal mention opt-in/out status for a network.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Network not found.")) + uid = ctx.author.id + mp = d.get("mention_policy", {}) + optouts = d.get("mention_optout_users", []) + exempt = d.get("mention_exempt_users", []) + if uid in exempt: + status = "🛡️ You are **exempt** — your mentions always pass through." + elif not mp.get("allow_user_mentions", False): + status = "🔇 All user mentions are **disabled network-wide** — opt-in/out has no effect." + elif uid in optouts: + status = "🔕 You are **opted out** — your @mentions are stripped from relayed messages." + else: + status = "🔔 You are **opted in** (default) — your @mentions are relayed normally." + await ctx.send(embed=info_embed(status, title=f"Your Ping Status — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # PHASE 5 — RULES / TERMS OF SERVICE ACCEPTANCE + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + _LEGAL_TEMPLATE = ( + "**WORMHOLE NETWORK TERMS OF SERVICE & CODE OF CONDUCT**\n\n" + "By using this cross-server communication network (the \"Network\"), you agree " + "to the following terms. Violation may result in permanent removal and your " + "messages being reported to Discord Trust & Safety and/or law enforcement.\n\n" + "**§1 — Content Standards**\n" + "All content must be appropriate for a general audience (Rated G). You shall NOT:\n" + "• Post, share, or reference NSFW, explicit, or sexually suggestive content\n" + "• Discuss, promote, or reference illegal activities, substances, or services\n" + "• Share content depicting or promoting violence, gore, or self-harm\n" + "• Post personal information (\"doxing\") of any individual\n" + "• Distribute malware, phishing links, or malicious code\n\n" + "**§2 — Conduct & Harassment**\n" + "You shall NOT:\n" + "• Harass, bully, intimidate, or threaten any user\n" + "• Discriminate or use hate speech based on race, ethnicity, gender, sexual " + "orientation, religion, disability, or any protected characteristic\n" + "• Impersonate other users, staff, or Discord employees\n" + "• Spam, flood, or disrupt the network in any way\n" + "• Evade bans, mutes, or other moderation actions\n\n" + "**§3 — Privacy & Data**\n" + "• Your Discord user ID, username, and message metadata are processed for relay " + "functionality and moderation\n" + "• Message content may be logged for moderation and audit purposes\n" + "• Staff may review flagged content; all moderation is logged\n" + "• You consent to your messages being relayed to other servers in this network\n\n" + "**§4 — Intellectual Property**\n" + "• You retain ownership of your content but grant the network a non-exclusive " + "license to relay and display it\n" + "• Do not post copyrighted material without permission\n\n" + "**§5 — Enforcement & Liability**\n" + "• Network staff reserve the right to remove content and ban users at discretion\n" + "• Violations may be reported to Discord and/or relevant authorities\n" + "• Evidence of illegal activity will be preserved and forwarded to law enforcement\n" + "• The network operators are not liable for user-generated content\n" + "• These terms may be updated; continued use constitutes acceptance\n\n" + "**§6 — Agreement**\n" + "By typing the accept command, you acknowledge that you have read, understood, " + "and agree to abide by these terms. You understand that violations may result in " + "permanent removal from the network, reporting to Discord Trust & Safety, and " + "where applicable, referral to law enforcement authorities." + ) + + @wh.group(name="tos", aliases=["terms"], invoke_without_command=True) + async def wh_tos(self, ctx): + """Manage the Terms of Service / rules acceptance system.""" + await ctx.send_help(ctx.command) + + @wh_tos.command(name="enable") + async def wh_tos_enable(self, ctx, name: str): + """Require users to accept rules before talking. Sets default legal ToS if none exists.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + n[name]["rules_required"] = True + if not n[name].get("rules_text"): + n[name]["rules_text"] = self._LEGAL_TEMPLATE + await ctx.send(embed=ok_embed( + f"Rules acceptance is now *required* on **{name}**.\n" + "Users must run `[p]wh accept " + name + "` before they can relay messages.\n" + "Use `[p]wh tos set` to customise the terms." + )) + await self._audit(name, "tos_enabled", ctx.author) + + @wh_tos.command(name="disable") + async def wh_tos_disable(self, ctx, name: str): + """Disable rules acceptance requirement.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + n[name]["rules_required"] = False + await ctx.send(embed=ok_embed(f"Rules acceptance no longer required on **{name}**.")) + + @wh_tos.command(name="set") + async def wh_tos_set(self, ctx, name: str, *, text: str): + """Set custom rules/ToS text (replaces default template).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + if len(text) > 4000: + return await ctx.send(embed=err_embed("Text too long (max 4000 chars).")) + async with self.config.networks() as n: + n[name]["rules_text"] = text + await ctx.send(embed=ok_embed(f"ToS updated for **{name}** ({len(text)} chars).")) + + @wh_tos.command(name="template") + async def wh_tos_template(self, ctx, name: str): + """Reset to the built-in legal template.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + n[name]["rules_text"] = self._LEGAL_TEMPLATE + await ctx.send(embed=ok_embed(f"ToS reset to default legal template for **{name}**.")) + + @wh_tos.command(name="accepted") + async def wh_tos_accepted(self, ctx, name: str): + """View how many users have accepted the ToS.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + accepted = d.get("rules_accepted", {}) + lines = [f"**{len(accepted)}** users have accepted the ToS."] + for uid_str, ts in list(accepted.items())[:20]: + u = self.bot.get_user(int(uid_str)) + uname = u.display_name if u else uid_str + lines.append(f"• {uname} — {ts[:16]}") + if len(accepted) > 20: + lines.append(f"*...and {len(accepted) - 20} more.*") + await ctx.send(embed=info_embed("\n".join(lines), title=f"ToS Accepted — {name}")) + + @wh_tos.command(name="reset") + async def wh_tos_reset(self, ctx, name: str): + """Clear all acceptances (force everyone to re-accept).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + n[name]["rules_accepted"] = {} + await ctx.send(embed=ok_embed(f"All ToS acceptances cleared for **{name}**. Users must re-accept.")) + await self._audit(name, "tos_reset", ctx.author) + + @wh.command(name="accept") + async def wh_accept(self, ctx, name: str): + """View and accept a network's Terms of Service / rules.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + rules_text = d.get("rules_text", "") + if not rules_text: + return await ctx.send(embed=info_embed("This network has no rules set.", title=f"Rules — {name}")) + already = str(ctx.author.id) in d.get("rules_accepted", {}) + if already: + return await ctx.send(embed=info_embed("You've already accepted the rules.", title=f"Rules — {name}")) + # Show rules in pages if needed + if len(rules_text) <= 4000: + em = discord.Embed(title=f"📜 Terms of Service — {name}", description=rules_text, colour=COLOUR_INFO) + em.set_footer(text=f"Type '{ctx.clean_prefix}wh agree {name}' to accept these terms.") + await ctx.send(embed=em) + else: + # Split into chunks + for i in range(0, len(rules_text), 4000): + chunk = rules_text[i:i+4000] + em = discord.Embed(description=chunk, colour=COLOUR_INFO) + if i == 0: + em.title = f"📜 Terms of Service — {name}" + await ctx.send(embed=em) + await ctx.send(embed=info_embed(f"Type `{ctx.clean_prefix}wh agree {name}` to accept these terms.")) + + @wh.command(name="agree") + async def wh_agree(self, ctx, name: str): + """Confirm acceptance of a network's Terms of Service.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not d.get("rules_text"): + return await ctx.send(embed=err_embed("This network has no ToS.")) + uid = str(ctx.author.id) + if uid in d.get("rules_accepted", {}): + return await ctx.send(embed=info_embed("You've already accepted.")) + async with self.config.networks() as n: + n[name].setdefault("rules_accepted", {})[uid] = datetime.now(timezone.utc).isoformat() + await ctx.send(embed=ok_embed( + f"✅ You have accepted the Terms of Service for **{name}**.\n" + f"Your agreement has been recorded at {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}.\n" + "You may now send messages through this network." + )) + await self._audit(name, "tos_accepted", ctx.author) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # PHASE 5 — MOD EDIT / DELETE THROUGH NETWORK + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh_mod.command(name="edit") + @commands.guild_only() + async def wh_mod_edit(self, ctx, name: str, message_id: int, *, new_content: str): + """Edit a message across the entire network. Works with *any* copy's ID (original or relayed).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + # Resolve: if message_id is a relayed copy, find the original first + original_id = self.msg_map.get_original(name, message_id) or message_id + mapping = self.msg_map.get_relayed(name, original_id) + if not mapping: + return await ctx.send(embed=err_embed("Message not found in relay map. Only recent messages can be edited.")) + mode = d.get("relay_mode", "webhook") + edited = 0 + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + cm = self._get_override(d, ch_id, "relay_mode") or mode + if cm == "webhook": + wh = await self._wh(ch) + await wh.edit_message(mid, content=new_content) + else: + msg = await ch.fetch_message(mid) + if msg.author.id == self.bot.user.id: + await msg.edit(content=new_content) + edited += 1 + except: + pass + await ctx.send(embed=ok_embed(f"Edited across **{edited}** relayed copies.")) + await self._audit(name, "mod_edit", ctx.author, details=f"msg={message_id}, orig={original_id}, edited={edited}") + + @wh_mod.command(name="nuke", aliases=["network-delete"]) + @commands.guild_only() + async def wh_mod_nuke(self, ctx, name: str, message_id: int): + """Delete a message from the entire network. Works with *any* copy's ID (original or relayed).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + # Resolve: if message_id is a relayed copy, find the original first + original_id = self.msg_map.get_original(name, message_id) or message_id + mapping = self.msg_map.get_relayed(name, original_id) + if not mapping: + return await ctx.send(embed=err_embed("Message not found in relay map. Only recent messages can be deleted.")) + mode = d.get("relay_mode", "webhook") + deleted = 0 + # Delete all relayed copies + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + cm = self._get_override(d, ch_id, "relay_mode") or mode + if cm == "webhook": + wh = await self._wh(ch) + await wh.delete_message(mid) + else: + msg = await ch.fetch_message(mid) + await msg.delete() + deleted += 1 + except: + pass + # Also try deleting the original source message + for ch_id in d.get("channels", []): + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + orig = await ch.fetch_message(original_id) + await orig.delete() + deleted += 1 + break + except: + pass + # If the provided ID was different from original, also try deleting it directly + if message_id != original_id: + for ch_id in d.get("channels", []): + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + msg = await ch.fetch_message(message_id) + await msg.delete() + deleted += 1 + break + except: + pass + await ctx.send(embed=ok_embed(f"Deleted from **{deleted}** locations across the network.")) + await self._audit(name, "mod_network_delete", ctx.author, details=f"msg={message_id}, orig={original_id}, deleted={deleted}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # PHASE 5 — REPORT SYSTEM + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="report", invoke_without_command=True) + async def wh_report(self, ctx): + """Report messages or manage reports.""" + await ctx.send_help(ctx.command) + + @wh_report.command(name="message") + @commands.guild_only() + async def wh_report_msg(self, ctx, message_id: int = None, *, reason: str = "No reason provided"): + """Report a message. Reply to a message or provide a message ID.""" + # Anti-abuse cooldown (1 report per 60s per user) + now = time.time() + last = self._report_cooldowns.get(ctx.author.id, 0) + if now - last < 60: + return await ctx.send(embed=err_embed(f"Please wait {int(60 - (now - last))}s before reporting again."), delete_after=10) + + target = None + if ctx.message.reference and ctx.message.reference.message_id: + try: target = await ctx.channel.fetch_message(ctx.message.reference.message_id) + except: pass + elif message_id: + try: target = await ctx.channel.fetch_message(message_id) + except: pass + if not target: + return await ctx.send(embed=err_embed("Reply to a message or provide a valid message ID.")) + + # Find network + net_name = await self._net_for_ch(ctx.channel.id) + if not net_name: + return await ctx.send(embed=err_embed("This channel is not part of a wormhole network.")) + nd = await self._net(net_name) + if not nd: return + + # Create report + content_hash = hashlib.sha256((target.content or "").encode()).hexdigest()[:16] + async with self.config.networks() as n: + counter = n[net_name].get("report_counter", 0) + 1 + n[net_name]["report_counter"] = counter + report = { + "id": counter, + "reporter_id": ctx.author.id, + "author_id": target.author.id, + "author_name": str(target.author), + "content_preview": truncate(target.content or "[no text]", 200), + "content_hash": content_hash, + "reason": truncate(reason, 500), + "channel_id": ctx.channel.id, + "guild_id": ctx.guild.id, + "message_id": target.id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "resolved": False, + "resolved_by": None, + } + n[net_name].setdefault("reports", []).append(report) + # Cap at 200 reports + if len(n[net_name]["reports"]) > 200: + n[net_name]["reports"] = n[net_name]["reports"][-200:] + + self._report_cooldowns[ctx.author.id] = now + + # Notify reporter + await ctx.send(embed=ok_embed(f"Report #{counter} submitted. Staff has been notified."), delete_after=10) + try: await ctx.message.delete() + except: pass + + # Send to log channel + report_em = discord.Embed( + title=f"🚨 Report #{counter} — {net_name}", + colour=discord.Colour.red(), + timestamp=datetime.now(timezone.utc), + ) + report_em.add_field(name="Reported User", value=f"{target.author} (`{target.author.id}`)", inline=True) + report_em.add_field(name="Reporter", value=f"{ctx.author} (`{ctx.author.id}`)", inline=True) + report_em.add_field(name="Server", value=ctx.guild.name, inline=True) + report_em.add_field(name="Reason", value=reason[:1024], inline=False) + report_em.add_field(name="Message Content", value=f"```{truncate(target.content or '[no text]', 1000)}```", inline=False) + if target.attachments: + report_em.add_field(name="Attachments", value="\n".join(a.url for a in target.attachments[:5]), inline=False) + report_em.set_footer(text=f"Content hash: {content_hash} | Message ID: {target.id}") + + await self._log(nd, report_em) + + # DM network owner + owner = self.bot.get_user(nd.get("owner_id")) + if owner: + try: await owner.send(embed=report_em) + except: pass + + await self._audit(net_name, "report_filed", ctx.author, details=f"#{counter} against {target.author}") + + @wh_report.command(name="list") + async def wh_report_list(self, ctx, name: str, show_resolved: bool = False): + """View reports for a network (staff only).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + reports = d.get("reports", []) + if not show_resolved: + reports = [r for r in reports if not r.get("resolved")] + if not reports: + return await ctx.send(embed=info_embed("No reports." if not show_resolved else "No reports at all.", title=f"Reports — {name}")) + lines = [] + for r in reversed(reports[-20:]): + status = "✅" if r.get("resolved") else "⏳" + u = self.bot.get_user(r.get("author_id")) + author = u.display_name if u else str(r.get("author_name", "?")) + lines.append(f"{status} **#{r['id']}** — {author}: *{truncate(r.get('reason', ''), 60)}* ({r['timestamp'][:10]})") + await ctx.send(embed=info_embed("\n".join(lines), title=f"Reports — {name}")) + + @wh_report.command(name="resolve") + async def wh_report_resolve(self, ctx, name: str, report_id: int): + """Resolve a report.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + reports = n[name].get("reports", []) + for r in reports: + if r["id"] == report_id: + r["resolved"] = True + r["resolved_by"] = ctx.author.id + await ctx.send(embed=ok_embed(f"Report #{report_id} resolved.")) + await self._audit(name, "report_resolved", ctx.author, details=f"#{report_id}") + return + await ctx.send(embed=err_embed(f"Report #{report_id} not found.")) + + @wh_report.command(name="action") + async def wh_report_action(self, ctx, name: str, report_id: int, action: str): + """Take action on a report: ban, mute, warn, or dismiss.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + report = None + for r in d.get("reports", []): + if r["id"] == report_id: + report = r; break + if not report: + return await ctx.send(embed=err_embed(f"Report #{report_id} not found.")) + target_id = report.get("author_id") + action = action.lower() + if action == "ban": + async with self.config.networks() as n: + if target_id not in n[name].get("banned_users", []): + n[name].setdefault("banned_users", []).append(target_id) + await ctx.send(embed=ok_embed(f"User `{target_id}` banned from **{name}**.")) + elif action == "mute": + async with self.config.networks() as n: + if target_id not in n[name].get("muted_users", []): + n[name].setdefault("muted_users", []).append(target_id) + await ctx.send(embed=ok_embed(f"User `{target_id}` muted on **{name}**.")) + elif action == "warn": + target = self.bot.get_user(target_id) + if target: + try: + await target.send(embed=warn_embed( + f"⚠️ You have received a warning on the **{name}** wormhole network.\n" + f"Reason: {report.get('reason', 'Policy violation')}\n\n" + "Further violations may result in a permanent ban." + )) + await ctx.send(embed=ok_embed(f"Warning sent to {target.display_name}.")) + except: + await ctx.send(embed=err_embed("Couldn't DM the user.")) + else: + await ctx.send(embed=err_embed("User not found.")) + elif action == "dismiss": + pass + else: + return await ctx.send(embed=err_embed("Action must be: `ban`, `mute`, `warn`, or `dismiss`.")) + # Resolve the report + async with self.config.networks() as n: + for r in n[name].get("reports", []): + if r["id"] == report_id: + r["resolved"] = True + r["resolved_by"] = ctx.author.id + break + await self._audit(name, f"report_action_{action}", ctx.author, details=f"#{report_id} target={target_id}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # PHASE 5 — CONTEXT MENU CALLBACKS + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + async def _ctx_report_message(self, interaction: discord.Interaction, message: discord.Message): + """Context menu: Report a message to Wormhole.""" + net_name = await self._net_for_ch(message.channel.id) + if not net_name: + return await interaction.response.send_message("This channel is not part of a wormhole network.", ephemeral=True) + # Anti-abuse cooldown + now = time.time() + last = self._report_cooldowns.get(interaction.user.id, 0) + if now - last < 60: + return await interaction.response.send_message(f"Please wait {int(60 - (now - last))}s.", ephemeral=True) + + # Show modal for reason + modal = _ReportModal(self, net_name, message) + await interaction.response.send_modal(modal) + + async def _ctx_bookmark_message(self, interaction: discord.Interaction, message: discord.Message): + """Context menu: Bookmark a message.""" + bm = { + "content": truncate(message.content or "[no text]", 500), + "author": str(message.author), + "server": message.guild.name if message.guild else "DM", + "channel": message.channel.name, + "timestamp": message.created_at.isoformat(), + "jump_url": message.jump_url, + } + uid = str(interaction.user.id) + async with self.config.bookmarks() as bookmarks: + bookmarks.setdefault(uid, []).append(bm) + if len(bookmarks[uid]) > 50: + bookmarks[uid] = bookmarks[uid][-50:] + # DM the bookmark + try: + em = info_embed( + f"**{bm['author']}** in #{bm['channel']} ({bm['server']}):\n\n" + f"> {truncate(bm['content'], 300)}\n\n[Jump]({bm['jump_url']})", + title="🔖 Bookmark Saved" + ) + await interaction.user.send(embed=em) + except: + pass + await interaction.response.send_message("🔖 Bookmarked! Check your DMs.", ephemeral=True) + + async def _ctx_delete_message(self, interaction: discord.Interaction, message: discord.Message): + """Context menu: Delete a message from the entire network (staff only).""" + net_name = await self._net_for_ch(message.channel.id) + if not net_name: + return await interaction.response.send_message("Not a wormhole channel.", ephemeral=True) + nd = await self._net(net_name) + if not nd: + return await interaction.response.send_message("Network not found.", ephemeral=True) + if not await self._is_staff(nd, interaction.user.id) and not await self.bot.is_owner(interaction.user): + return await interaction.response.send_message("Staff only.", ephemeral=True) + + await interaction.response.defer(ephemeral=True) + mapping = self.msg_map.get_relayed(net_name, message.id) + deleted = 0 + # Delete relayed copies + if mapping: + mode = nd.get("relay_mode", "webhook") + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + cm = self._get_override(nd, ch_id, "relay_mode") or mode + if cm == "webhook": + wh = await self._wh(ch) + await wh.delete_message(mid) + else: + msg = await ch.fetch_message(mid) + await msg.delete() + deleted += 1 + except: + pass + # Delete original + try: + await message.delete() + deleted += 1 + except: + pass + await interaction.followup.send(f"Deleted from {deleted} location(s).", ephemeral=True) + await self._audit(net_name, "ctx_delete", interaction.user, details=f"msg={message.id}") + + async def _ctx_view_profile(self, interaction: discord.Interaction, user: discord.User): + """Context menu: View a user's Wormhole profile.""" + nets = await self.config.networks() + found = [] + for name, data in nets.items(): + profiles = data.get("user_profiles", {}) + uid = str(user.id) + if uid in profiles: + p = profiles[uid] + found.append((name, p)) + if not found: + return await interaction.response.send_message(f"{user.display_name} has no Wormhole profiles.", ephemeral=True) + em = discord.Embed(title=f"👤 {user.display_name} — Wormhole Profile", colour=COLOUR_INFO) + em.set_thumbnail(url=user.display_avatar.url) + for name, p in found[:5]: + msgs = p.get("messages", 0) + servers = len(p.get("guilds", [])) + karma_data = (await self._net(name) or {}).get("karma_scores", {}) + karma = karma_data.get(str(user.id), 0) + em.add_field( + name=name, + value=f"Messages: **{msgs:,}** | Servers: **{servers}** | Karma: **{karma}**", + inline=False, + ) + await interaction.response.send_message(embed=em, ephemeral=True) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # DEBUG + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="debug") + @commands.guild_only() + async def wh_debug(self, ctx): + """Diagnose relay issues — shows cog state for this channel.""" + lines = [] + lines.append(f"**Cog ready:** {'✅' if self._ready.is_set() else '❌ (init still running!)'}") + lines.append(f"**Bot user:** {self.bot.user} (ID: {self.bot.user.id})") + + # Intents + intents = self.bot.intents + lines.append(f"**Intents:** message_content={'✅' if intents.message_content else '❌ MISSING'} " + f"members={'✅' if intents.members else '❌'} " + f"reactions={'✅' if intents.reactions else '❌'}") + + # Network for this channel + nets = await self.config.networks() + lines.append(f"**Total networks:** {len(nets)}") + + net_name = None + nd = None + for n, d in nets.items(): + if ctx.channel.id in d.get("channels", []): + net_name = n; nd = d; break + + if not net_name: + lines.append(f"\n❌ **This channel is NOT linked to any network.**") + lines.append(f"Channel ID: `{ctx.channel.id}`") + # Show all registered channel IDs for debugging + all_ch = [] + for n, d in nets.items(): + for cid in d.get("channels", []): + all_ch.append(f"`{cid}` → {n}") + if all_ch: + lines.append(f"Registered channels:\n" + "\n".join(all_ch[:15])) + else: + lines.append(f"\n✅ **Network:** `{net_name}`") + lines.append(f"**Frozen:** {'⚠️ YES' if nd.get('frozen') else '❌ No'}") + lines.append(f"**Relay mode:** `{nd.get('relay_mode', 'webhook')}`") + lines.append(f"**Channels:** {len(nd.get('channels', []))}") + lines.append(f"**Total messages:** {nd.get('total_messages', 0):,}") + + # Test webhook + if nd.get("relay_mode", "webhook") == "webhook": + try: + perms = ctx.channel.permissions_for(ctx.guild.me) + lines.append(f"**Manage Webhooks perm:** {'✅' if perms.manage_webhooks else '❌ MISSING — need this!'}") + if perms.manage_webhooks: + wh = await self._wh(ctx.channel) + lines.append(f"**Webhook:** ✅ `{wh.name}` (ID: {wh.id})") + except Exception as e: + lines.append(f"**Webhook:** ❌ Error: `{e}`") + + # List connected channels and their status + ch_lines = [] + for cid in nd.get("channels", []): + ch = self.bot.get_channel(cid) + if ch: + perms = ch.permissions_for(ch.guild.me) + wh_ok = "✅" if perms.manage_webhooks else "⚠️ no wh" + send_ok = "✅" if perms.send_messages else "❌ no send" + ch_lines.append(f"• **{ch.guild.name}** › #{ch.name} {wh_ok} {send_ok}") + else: + ch_lines.append(f"• `{cid}` — ⚠️ channel not visible to bot") + if ch_lines: + lines.append(f"\n**Connected channels:**\n" + "\n".join(ch_lines[:15])) + + # Prefixes + pfx = await self.bot.get_prefix(ctx.message) + if isinstance(pfx, str): pfx = [pfx] + pfx_display = [f"`{p}`" if p.strip() else f"`(empty)`" for p in pfx[:5]] + lines.append(f"\n**Prefixes:** {', '.join(pfx_display)}") + empty_pfx = any(p == "" for p in pfx) + if empty_pfx: + lines.append("⚠️ **Empty prefix detected!** Messages matching commands via the empty prefix " + "may be incorrectly filtered. Use `[p]set prefix` to remove it.") + lines.append("**Command filter:** `bot.get_context()` — only prefixed commands are skipped") + + await ctx.send(embed=info_embed("\n".join(lines), title="🔧 Wormhole Debug")) + + @wh.command(name="relaydebug", aliases=["rd"]) + @commands.guild_only() + async def wh_relaydebug(self, ctx): + """Run every relay gate-check against *this* channel and report the results.""" + message = ctx.message + lines: list[str] = [] + + lines.append(f"**Channel:** {ctx.channel.mention} (ID: `{ctx.channel.id}`)") + + eff_ch = message.channel.id + is_thread = isinstance(message.channel, discord.Thread) + if is_thread: + eff_ch = message.channel.parent_id + lines.append(f"Thread → parent channel ID: `{eff_ch}`") + + nets = await self.config.networks() + net_name = None + nd = None + for n, d in nets.items(): + if eff_ch in d.get("channels", []): + net_name = n + nd = d + break + + if not net_name: + lines.append("❌ **Channel not found in any network!**") + for n, d in nets.items(): + ch_ids = d.get("channels", []) + types = [type(c).__name__ for c in ch_ids] + lines.append(f" `{n}`: channels=`{ch_ids}` types=`{types}`") + lines.append(f" Looking for: `{eff_ch}` (type: `{type(eff_ch).__name__}`)") + return await ctx.send(embed=info_embed("\n".join(lines), title="🔍 Relay Debug")) + + lines.append(f"✅ **Network:** `{net_name}`") + + # Frozen + lines.append(f"{'❌' if nd.get('frozen') else '✅'} Frozen: {nd.get('frozen', False)}") + + # Thread sync + if is_thread: + lines.append(f"{'✅' if nd.get('sync_threads') else '❌'} Thread sync: {nd.get('sync_threads', False)}") + + # Mirror + is_mirror = eff_ch in nd.get("mirror_channels", []) + lines.append(f"{'❌ Channel is receive-only mirror!' if is_mirror else '✅ Not a mirror channel'}") + + # Media-only + lines.append(f"{'⚠️ Media-only mode (text dropped)' if nd.get('media_only') else '✅ media_only: off'}") + + # NSFW gate + nsfw_gate = nd.get("nsfw_gate", False) + ch_nsfw = hasattr(message.channel, "is_nsfw") and message.channel.is_nsfw() + if nsfw_gate and ch_nsfw: + lines.append("❌ **NSFW gate BLOCKING** — channel is NSFW + nsfw_gate=True") + else: + lines.append(f"✅ nsfw_gate={nsfw_gate}, channel_nsfw={ch_nsfw}") + + # User / server bans + gbu = await self.config.global_banned_users() + gbs = await self.config.global_banned_servers() + if message.author.id in gbu: + lines.append("❌ **User globally banned**") + elif message.author.id in nd.get("banned_users", []): + lines.append("❌ **User banned in network**") + elif message.author.id in nd.get("muted_users", []): + lines.append("❌ **User muted in network**") + else: + lines.append("✅ User not banned/muted") + + if message.guild.id in gbs: + lines.append("❌ **Server globally banned**") + elif message.guild.id in nd.get("banned_servers", []): + lines.append("❌ **Server banned in network**") + elif message.guild.id in nd.get("muted_servers", []): + lines.append("❌ **Server muted in network**") + else: + lines.append("✅ Server not banned/muted") + + # Rules gate + if nd.get("rules_required"): + lines.append(f"{'❌ Rules NOT accepted' if str(message.author.id) not in nd.get('rules_accepted', {}) else '✅ Rules accepted'}") + else: + lines.append("✅ rules_required: off") + + # Command filter + try: + test_ctx = await self.bot.get_context(message) + if test_ctx.valid and test_ctx.prefix: + lines.append(f"⚠️ Command filter: would skip (prefix=`{test_ctx.prefix!r}` cmd=`{test_ctx.command}`)") + elif test_ctx.valid: + lines.append(f"✅ Command filter: matched empty prefix (ignored, prefix=`{test_ctx.prefix!r}`)") + else: + lines.append("✅ Command filter: not a command") + except Exception as exc: + lines.append(f"✅ Command filter: get_context error ({exc})") + + # Relay targets + relay_targets = [cid for cid in nd.get("channels", []) if cid != eff_ch] + for bn in nd.get("bridge_to", []): + bd = nets.get(bn) + if bd and not bd.get("frozen"): + relay_targets.extend(bd.get("channels", [])) + if not relay_targets: + lines.append("❌ **No relay targets!** Need ≥2 channels in the network") + else: + lines.append(f"✅ **Relay targets:** {len(relay_targets)}") + for cid in relay_targets[:5]: + ch = self.bot.get_channel(cid) + if ch: + p = ch.permissions_for(ch.guild.me) + lines.append(f" → #{ch.name} ({ch.guild.name}) wh={'✅' if p.manage_webhooks else '❌'} send={'✅' if p.send_messages else '❌'}") + else: + lines.append(f" → `{cid}` ⚠️ channel not visible to bot") + + # Rate limit + bucket = self.cooldowns.get(net_name) + if bucket and bucket.is_rate_limited(message.author.id, net_name): + lines.append("⚠️ **Rate limited right now**") + else: + lines.append("✅ Not rate-limited") + + await ctx.send(embed=info_embed("\n".join(lines), title="🔍 Relay Debug")) + + @wh.command(name="testsend") + @commands.guild_only() + async def wh_testsend(self, ctx, *, text: str = "🧪 Test relay message from Wormhole"): + """Bypass on_message and send a test message directly through the relay webhook.""" + eff_ch = ctx.channel.id + nets = await self.config.networks() + net_name = None + nd = None + for n, d in nets.items(): + if eff_ch in d.get("channels", []): + net_name = n; nd = d; break + if not net_name: + return await ctx.send(embed=err_embed("This channel is not in any wormhole network.")) + + relay_targets = [cid for cid in nd.get("channels", []) if cid != eff_ch] + if not relay_targets: + return await ctx.send(embed=err_embed("No other channels in this network to relay to.")) + + results = [] + for ch_id in relay_targets: + ch = self.bot.get_channel(ch_id) + if not ch: + results.append(f"❌ `{ch_id}` — channel not visible to bot") + continue + try: + wh = await self._wh(ch) + await wh.send( + content=text, + username=truncate(ctx.author.display_name, 80), + avatar_url=ctx.author.display_avatar.url, + wait=True, + ) + results.append(f"✅ #{ch.name} ({ch.guild.name}) — webhook send OK") + except Exception as exc: + results.append(f"❌ #{ch.name} — `{exc}`") + await ctx.send(embed=info_embed("\n".join(results), title="🧪 Test Relay Results")) + + @wh.command(name="tracemode") + @commands.guild_only() + async def wh_tracemode(self, ctx): + """Toggle live relay tracing for this channel. When on, the bot posts a + diagnostic embed for every message showing which gate blocked (or passed) it.""" + cid = ctx.channel.id + if cid in self._trace_channels: + self._trace_channels.discard(cid) + await ctx.send(embed=info_embed("Relay tracing **disabled** for this channel.")) + else: + self._trace_channels.add(cid) + await ctx.send(embed=info_embed("Relay tracing **enabled** — send a normal message and check the trace output.\nRun again to disable.")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # MESSAGE RELAY ENGINE + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + await self._ready.wait() + if not message.guild or message.author.bot: + return + if not isinstance(message.channel, (discord.TextChannel, discord.Thread)): + return + + _tracing = message.channel.id in self._trace_channels + trace: list[str] = [] + if _tracing: + trace.append("✅ Guild text-channel message from non-bot") + + eff_ch = message.channel.id + is_thread = isinstance(message.channel, discord.Thread) + if is_thread: + eff_ch = message.channel.parent_id + + nets = await self.config.networks() + net_name = None + nd = None + for n, d in nets.items(): + if eff_ch in d.get("channels", []): + net_name = n; nd = d; break + if not net_name: + if _tracing: + trace.append(f"❌ **Channel `{eff_ch}` not in any network**") + for n, d in nets.items(): + trace.append(f" `{n}`: `{d.get('channels', [])}`") + try: await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except: pass + return + + if _tracing: + trace.append(f"✅ Network: `{net_name}`") + + if is_thread and not nd.get("sync_threads"): + if _tracing: + trace.append("❌ **Blocked: thread sync disabled**") + try: await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except: pass + return + if nd.get("frozen"): + if _tracing: + trace.append("❌ **Blocked: network frozen**") + try: await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except: pass + return + + # ── COMMAND FILTERING ─────────────────────────────────────────────── + # Use Red's internal command resolution — skip messages that resolve + # to a registered bot command. ctx.valid means both a prefix matched + # AND a real command was found, so this works correctly even with an + # empty-string prefix (only actual commands are skipped, not all text). + if message.content: + try: + ctx = await self.bot.get_context(message) + if ctx.valid: + if _tracing: + trace.append(f"❌ **Blocked: command filter** prefix=`{ctx.prefix!r}` cmd=`{ctx.command}`") + try: await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except: pass + return + if _tracing: + trace.append(f"✅ Command filter passed (valid={ctx.valid} prefix={ctx.prefix!r})") + except Exception: + if _tracing: + trace.append("✅ Command filter: get_context errored, continuing") + pass + + # ── Mirror channel check (receive-only channels don't send) ──────── + if eff_ch in nd.get("mirror_channels", []): + if _tracing: + trace.append("❌ **Blocked: mirror channel (receive-only)**") + try: await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except: pass + return + + # ── Media-only filter ────────────────────────────────────────────── + if nd.get("media_only"): + if not message.attachments and not message.stickers and not (message.embeds and any(e.type in ("image", "video", "gifv") for e in message.embeds)): + if _tracing: + trace.append("❌ **Blocked: media_only — no media attached**") + try: await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except: pass + return + + # NSFW gate + if nd.get("nsfw_gate") and hasattr(message.channel, "is_nsfw") and message.channel.is_nsfw(): + if _tracing: + trace.append("❌ **Blocked: NSFW gate — channel is NSFW**") + try: await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except: pass + return + + # Global blocklist + if message.author.id in await self.config.global_banned_users(): + if _tracing: + trace.append("❌ **Blocked: user globally banned**") + try: await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except: pass + return + if message.guild.id in await self.config.global_banned_servers(): + if _tracing: + trace.append("❌ **Blocked: server globally banned**") + try: await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except: pass + return + + # Per-network checks + if message.author.id in nd.get("banned_users", []): + if _tracing: + trace.append("❌ **Blocked: user banned in network**") + try: await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except: pass + return + if message.author.id in nd.get("muted_users", []): + if _tracing: + trace.append("❌ **Blocked: user muted in network**") + try: await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except: pass + return + if message.guild.id in nd.get("banned_servers", []): + if _tracing: + trace.append("❌ **Blocked: server banned in network**") + try: await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except: pass + return + if message.guild.id in nd.get("muted_servers", []): + if _tracing: + trace.append("❌ **Blocked: server muted in network**") + try: await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except: pass + return + + # Rules acceptance gate (Phase 5) + if nd.get("rules_required"): + accepted = nd.get("rules_accepted", {}) + if str(message.author.id) not in accepted: + if _tracing: + trace.append("❌ **Blocked: rules not accepted**") + try: await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except: pass + try: + prefix = (await self.bot.get_prefix(message)) + if isinstance(prefix, list): + prefix = prefix[0] + await message.channel.send( + embed=warn_embed( + f"{message.author.mention}, you must accept the network rules before messaging.\n" + f"Use `{prefix}wh accept {net_name}` to view and accept." + ), + delete_after=15, + ) + except: + pass + return + + # Content filter + if message.content: + if check_filters(message.content, nd.get("word_filters", []), nd.get("regex_filters", [])): + try: + await message.delete() + await message.channel.send(embed=warn_embed(f"{message.author.mention}, blocked by filter."), delete_after=5) + except: pass + return + + # Attachment filter + if message.attachments: + exts = set(nd.get("blocked_extensions", [])) + reason = check_attachment_filters(message.attachments, exts, nd.get("max_filesize")) + if reason: + try: + await message.delete() + await message.channel.send(embed=warn_embed(f"{message.author.mention}, {reason}"), delete_after=5) + except: pass + return + + # Auto-moderation + am = nd.get("automod", {}) + if am.get("enabled") and message.content: + if am.get("anti_spam"): + det = self.dup_detectors.get(net_name) + if det and det.is_duplicate(net_name, message.author.id, message.content): + try: await message.delete(); await message.channel.send(embed=warn_embed("Spam detected."), delete_after=5) + except: pass + await self._log(nd, warn_embed(f"Auto-mod spam: {message.author}")) + return + if am.get("anti_raid"): + rd = self.raid_detectors.get(net_name) + if rd and rd.record(net_name, message.author.id): + async with self.config.networks() as ns: + if net_name in ns: ns[net_name]["frozen"] = True + await self._log(nd, warn_embed(f"🚨 Raid detected! Network auto-frozen.")) + await self._status(net_name, nd, None, "🚨 **Raid detected — network auto-frozen!** Staff: use `wh set freeze false` to unfreeze.") + return + reason = check_automod(message.content, am) + if reason: + try: await message.delete(); await message.channel.send(embed=warn_embed(f"{message.author.mention}: {reason}"), delete_after=5) + except: pass + await self._log(nd, warn_embed(f"Auto-mod: {reason} — {message.author}")) + return + + # Rate limit + bucket = self.cooldowns.get(net_name) + if bucket and bucket.is_rate_limited(message.author.id, net_name): + try: await message.add_reaction("🕐") + except: pass + return + + # Slowmode + sm = nd.get("slowmode", 0) + if sm > 0: + last = self.slowmode_tracker.get(net_name, {}).get(message.author.id, 0) + if time.monotonic() - last < sm: + try: await message.add_reaction("🐌") + except: pass + return + self.slowmode_tracker.setdefault(net_name, {})[message.author.id] = time.monotonic() + + # Relay delay + delay = nd.get("relay_delay", 0) + if delay > 0: + await asyncio.sleep(min(delay, 30)) + + # ── AFK system ───────────────────────────────────────────────────── + try: + await self._check_afk(net_name, nd, message) + except Exception as exc: + log.debug("AFK check error (relay continues): %s", exc) + + # ── Auto-responses ───────────────────────────────────────────────── + try: + await self._check_auto_responses(net_name, nd, message) + except Exception as exc: + log.debug("Auto-response error (relay continues): %s", exc) + + # ── Build payload & relay ────────────────────────────────────────── + # Top-level safety net: if ANYTHING below throws, log it instead + # of silently killing the relay for this message. + if _tracing: + trace.append("✅ All gate checks passed — calling _do_relay") + try: + await self._do_relay(net_name, nd, nets, message, eff_ch) + if _tracing: + trace.append("✅ **_do_relay completed successfully**") + try: await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace — RELAYED")) + except: pass + except Exception as exc: + log.error("Relay engine error net=%s ch=%s: %s", net_name, message.channel.id, exc, exc_info=True) + if _tracing: + trace.append(f"❌ **_do_relay EXCEPTION: `{exc}`**") + try: await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace — ERROR")) + except: pass + + async def _do_relay(self, net_name: str, nd: dict, nets: dict, message: discord.Message, eff_ch: int): + """Core relay logic — separated so on_message can wrap it in try/except.""" + relay_mode = nd.get("relay_mode", "webhook") + nick = nd.get("server_nicknames", {}).get(str(message.guild.id)) + # Mention control — Phase 5 granular policy takes priority, falls back to legacy + mp = nd.get("mention_policy", {}) + server_overrides = nd.get("server_mention_overrides", {}).get(str(message.guild.id)) + active_policy = server_overrides if server_overrides else mp + exempt = nd.get("mention_exempt_users", []) + optouts = set(nd.get("mention_optout_users", [])) + if active_policy: + content = apply_mention_policy(message.content or "", active_policy, message.author.id, exempt, optouts) + else: + mc = nd.get("mention_control", {}) + content = sanitise_mentions(message.content or "", mc) + + # Anonymous mode + is_anon = nd.get("anonymous", False) + if is_anon: + anon_name = self._anon_name(nd, message.author.id) + avatar = self._anon_avatar(message.author.id) + uname = anon_name + else: + avatar = self._avatar(message, nd.get("image_mode", "user"), nd.get("custom_icon")) + uname = self._name(message, nd.get("name_mode", "both"), nd.get("custom_name"), nick) + + # User vanity colour (for embed mode) + user_colour = nd.get("user_colours", {}).get(str(message.author.id)) + + # Reply context + reply_jump_urls: Dict[int, str] = {} # {target_ch_id: jump_url} + reply_fallback_url: Optional[str] = None + if nd.get("sync_replies") and message.reference and message.reference.message_id: + try: + ref = message.reference.cached_message or await message.channel.fetch_message(message.reference.message_id) + ref_name = self._anon_name(nd, ref.author.id) if is_anon else ref.author.display_name + preview = truncate(ref.content, 100) if ref.content else "*[attachment]*" + content = f"> **↩ {ref_name}:** {preview}\n{content}" + + # Build per-channel "Jump to replied message" URLs + if ref.guild: + reply_fallback_url = f"https://discord.com/channels/{ref.guild.id}/{ref.channel.id}/{ref.id}" + # Look up relayed copies of the referenced message + orig_ref_id = self.msg_map.get_original(net_name, ref.id) + ref_relayed = ( + self.msg_map.forward.get(net_name, {}).get(orig_ref_id, {}) + if orig_ref_id + else self.msg_map.forward.get(net_name, {}).get(ref.id, {}) + ) + # Map each relay-target channel to its local copy + for cid, mid in ref_relayed.items(): + target_ch = self.bot.get_channel(cid) + if target_ch and target_ch.guild: + reply_jump_urls[cid] = f"https://discord.com/channels/{target_ch.guild.id}/{cid}/{mid}" + # Map the source channel (where the original was typed). + # The forward map only stores relay targets — the source + # channel is whichever network channel is NOT in the map. + if orig_ref_id: + # ref was a relayed copy — find the original's channel + relayed_ch_ids = set(ref_relayed.keys()) + for net_ch_id in nd.get("channels", []): + if net_ch_id not in relayed_ch_ids and net_ch_id not in reply_jump_urls: + net_ch = self.bot.get_channel(net_ch_id) + if net_ch and net_ch.guild: + reply_jump_urls[net_ch_id] = f"https://discord.com/channels/{net_ch.guild.id}/{net_ch_id}/{orig_ref_id}" + # Include the channel where the ref message lives + reply_jump_urls.setdefault(ref.channel.id, reply_fallback_url) + except: content = f"> ↩ *[reply]*\n{content}" + + # Stickers + if nd.get("sync_stickers") and message.stickers: + sl = [f"[Sticker: {s.name}]({s.url})" for s in message.stickers] + content = (content + "\n" if content else "") + "\n".join(sl) + + # Embeds + extra_embeds = [] + if nd.get("forward_embeds") and message.embeds: + extra_embeds = [e for e in message.embeds if e.type == "rich"] + + # ── Resolve foreign emojis ───────────────────────────────────────── + # Unknown custom emojis are replaced with :name: in text. The emoji + # image is downloaded and shown as an embed thumbnail (~80×80 px) + # via the attachment:// scheme so it stays close to real emoji size. + emoji_img_data: list[tuple[str, bytes]] = [] + if content: + content, emoji_img_data = await _resolve_foreign_emojis(self.bot, content) + + # ── Relay to channels ────────────────────────────────────────────── + mapping: Dict[int, int] = {} + # Build target list: own channels + bridge_to networks' channels + relay_targets = [cid for cid in nd["channels"] if cid != eff_ch] + for bridge_net in nd.get("bridge_to", []): + bd = nets.get(bridge_net) + if bd and not bd.get("frozen"): + relay_targets.extend(bd.get("channels", [])) + + for ch_id in relay_targets: + if ch_id == eff_ch: continue + ch = self.bot.get_channel(ch_id) + if not ch: continue + ch_mode = self._get_override(nd, ch_id, "relay_mode") or relay_mode + + # Build reply-jump view for this channel (if replying) + jump_url = reply_jump_urls.get(ch_id) or reply_fallback_url + reply_view = _reply_jump_view(jump_url) if jump_url else None + _view_kw = {"view": reply_view} if reply_view else {} + + # Fresh emoji embed-thumbnail pairs (File objects are one-use) + emoji_embeds, emoji_files = _emoji_embeds_and_files(emoji_img_data) + + try: + sent_msg = None + if ch_mode == "webhook": + try: + wh = await self._wh(ch) + files = [] + for a in message.attachments: + try: files.append(await a.to_file()) + except: pass + files.extend(emoji_files) + all_embeds = (extra_embeds or []) + emoji_embeds + send_content = content if content else None + if not send_content and not files and not all_embeds: + send_content = "*[empty message]*" + sent_msg = await wh.send( + content=send_content, + username=truncate(uname, 80), + avatar_url=avatar, + files=files or discord.utils.MISSING, + embeds=all_embeds[:10] or discord.utils.MISSING, + wait=True, + **_view_kw, + ) + mapping[ch_id] = sent_msg.id + except (discord.NotFound, discord.InvalidData): + try: + wh = await self._wh(ch, force_refresh=True) + files2 = [] + for a in message.attachments: + try: files2.append(await a.to_file()) + except: pass + # Rebuild fresh emoji files (consumed in prior attempt) + ee2, ef2 = _emoji_embeds_and_files(emoji_img_data) + files2.extend(ef2) + all_embeds2 = (extra_embeds or []) + ee2 + send_content = content if content else None + if not send_content and not files2 and not all_embeds2: + send_content = "*[empty message]*" + sent_msg = await wh.send( + content=send_content, + username=truncate(uname, 80), + avatar_url=avatar, + files=files2 or discord.utils.MISSING, + embeds=all_embeds2[:10] or discord.utils.MISSING, + wait=True, + **_view_kw, + ) + mapping[ch_id] = sent_msg.id + except Exception: + log.warning("Webhook retry failed for %s, falling back to embed", ch_id) + em = build_relay_embed(message, nick, nd.get("colour")) + sent_msg = await ch.send(embeds=[em] + extra_embeds[:9], **_view_kw) + mapping[ch_id] = sent_msg.id + except discord.Forbidden: + log.warning("No webhook perms in %s, falling back to embed", ch_id) + em = build_relay_embed(message, nick, nd.get("colour")) + sent_msg = await ch.send(embeds=[em] + extra_embeds[:9], **_view_kw) + mapping[ch_id] = sent_msg.id + elif ch_mode == "embed": + em = build_relay_embed(message, nick, user_colour or nd.get("colour")) + # Rebuild fresh emoji files for embed mode + ee, ef = _emoji_embeds_and_files(emoji_img_data) + all_embeds_e = [em] + (extra_embeds or []) + ee + sent_msg = await ch.send( + embeds=all_embeds_e[:10], + files=ef or None, + **_view_kw, + ) + mapping[ch_id] = sent_msg.id + elif ch_mode == "compact": + g = nick or message.guild.name + display = anon_name if is_anon else message.author.display_name + files = [] + for a in message.attachments: + try: files.append(await a.to_file()) + except: pass + # Rebuild fresh emoji files for compact mode + ce, cf = _emoji_embeds_and_files(emoji_img_data) + files.extend(cf) + sent_msg = await ch.send( + content=compact_format(g, display, content), + files=files or None, + embeds=ce[:10] or None, + **_view_kw, + ) + mapping[ch_id] = sent_msg.id + + # Ephemeral deletion + if sent_msg and nd.get("ephemeral_delay", 0) > 0: + await self._schedule_ephemeral_delete(sent_msg, nd["ephemeral_delay"]) + + except Exception as exc: + log.error("Relay fail ch=%s net=%s: %s", ch_id, net_name, exc, exc_info=True) + + if mapping: + self.msg_map.add(net_name, message.id, mapping) + + # Stats + profile + analytics + async with self.config.networks() as ns: + if net_name in ns: ns[net_name]["total_messages"] = ns[net_name].get("total_messages", 0) + 1 + await self._update_profile(net_name, message.author, message.guild.id) + await self._record_analytics(net_name, message.author.id) + + # DM relay + await self._relay_to_dm_subs(net_name, nd, message) + + # Keyword highlights + await self._check_highlights(net_name, nd, message.content, message.author.id) + + # ── Edit sync ─────────────────────────────────────────────────────────── + + @commands.Cog.listener() + async def on_message_edit(self, before: discord.Message, after: discord.Message): + await self._ready.wait() + if not after.guild or after.author.bot or before.content == after.content: return + eff = after.channel.id + if isinstance(after.channel, discord.Thread): eff = after.channel.parent_id + net = await self._net_for_ch(eff) + if not net: return + nd = await self._net(net) + if not nd or not nd.get("sync_edits"): return + mapping = self.msg_map.get_relayed(net, after.id) + if not mapping: return + mode = nd.get("relay_mode", "webhook") + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + cm = self._get_override(nd, ch_id, "relay_mode") or mode + if cm == "webhook": + wh = await self._wh(ch) + await wh.edit_message(mid, content=after.content or "") + elif cm == "embed": + msg = await ch.fetch_message(mid) + if msg.embeds: + e = msg.embeds[0]; e.description = after.content or "*[no text]*" + await msg.edit(embed=e) + else: + msg = await ch.fetch_message(mid) + cp = msg.content.find(":** ") + if cp != -1: await msg.edit(content=truncate(msg.content[:cp+4] + after.content, 2000)) + except: pass + + # ── Delete sync ───────────────────────────────────────────────────────── + + @commands.Cog.listener() + async def on_message_delete(self, message: discord.Message): + await self._ready.wait() + if not message.guild or message.author.bot: return + eff = message.channel.id + if isinstance(message.channel, discord.Thread): eff = message.channel.parent_id + net = await self._net_for_ch(eff) + if not net: return + nd = await self._net(net) + if not nd or not nd.get("sync_deletes"): return + mapping = self.msg_map.get_relayed(net, message.id) + if not mapping: return + mode = nd.get("relay_mode", "webhook") + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + cm = self._get_override(nd, ch_id, "relay_mode") or mode + if cm == "webhook": + wh = await self._wh(ch) + await wh.delete_message(mid) + else: + msg = await ch.fetch_message(mid) + await msg.delete() + except: pass + + # ── Reaction sync + karma + starboard ────────────────────────────────── + + @commands.Cog.listener() + async def on_reaction_add(self, reaction: discord.Reaction, user: discord.User): + await self._ready.wait() + if user.bot: return + msg = reaction.message + if not msg.guild: return + eff = msg.channel.id + if isinstance(msg.channel, discord.Thread): eff = msg.channel.parent_id + net = await self._net_for_ch(eff) + if not net: return + nd = await self._net(net) + if not nd: return + + # Reaction sync + if nd.get("sync_reactions"): + mapping = self.msg_map.get_relayed(net, msg.id) + if mapping: + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + rm = await ch.fetch_message(mid) + await rm.add_reaction(reaction.emoji) + except: pass + + # Karma + if nd.get("karma_enabled") and str(reaction.emoji) == nd.get("karma_emoji", "👍"): + # Find the original author + orig_id = self.msg_map.get_original(net, msg.id) + target_msg = msg + if orig_id: + # This is a relayed message — find original author via profiles or just give to the content + pass # Karma for relayed messages: credit the original sender + if target_msg.author.id != user.id: # Can't karma yourself + uid = str(target_msg.author.id) + async with self.config.networks() as ns: + if net in ns: + ns[net].setdefault("karma_scores", {}) + ns[net]["karma_scores"][uid] = ns[net]["karma_scores"].get(uid, 0) + 1 + + # Starboard + if nd.get("starboard_enabled") and str(reaction.emoji) == "⭐": + threshold = nd.get("starboard_threshold", 3) + star_ch_id = nd.get("starboard_channel") + if not star_ch_id: return + star_ch = self.bot.get_channel(star_ch_id) + if not star_ch: return + + # Count total stars across all relayed copies + original + total_stars = 0 + for r in msg.reactions: + if str(r.emoji) == "⭐": + total_stars += r.count + + if total_stars >= threshold: + starred = nd.get("starred_messages", {}) + msg_key = str(msg.id) + img_url = None + if msg.attachments: + for a in msg.attachments: + if a.content_type and a.content_type.startswith("image/"): + img_url = a.url; break + em = build_star_embed( + msg.author.display_name, msg.author.display_avatar.url, + msg.content or "*[no text]*", total_stars, + msg.guild.name, msg.channel.name, img_url) + + if msg_key in starred: + # Update existing + try: + board_msg = await star_ch.fetch_message(starred[msg_key]["board_msg_id"]) + await board_msg.edit(embed=em) + async with self.config.networks() as ns: + if net in ns: ns[net]["starred_messages"][msg_key]["stars"] = total_stars + except: pass + else: + try: + board_msg = await star_ch.send(embed=em) + async with self.config.networks() as ns: + if net in ns: + ns[net].setdefault("starred_messages", {})[msg_key] = {"stars": total_stars, "board_msg_id": board_msg.id} + except: pass + + @commands.Cog.listener() + async def on_reaction_remove(self, reaction: discord.Reaction, user: discord.User): + await self._ready.wait() + if user.bot: return + msg = reaction.message + if not msg.guild: return + eff = msg.channel.id + if isinstance(msg.channel, discord.Thread): eff = msg.channel.parent_id + net = await self._net_for_ch(eff) + if not net: return + nd = await self._net(net) + if not nd or not nd.get("sync_reactions"): return + mapping = self.msg_map.get_relayed(net, msg.id) + if not mapping: return + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + rm = await ch.fetch_message(mid) + await rm.remove_reaction(reaction.emoji, self.bot.user) + except: pass + + # ── Typing indicator sync ─────────────────────────────────────────────── + + @commands.Cog.listener() + async def on_typing(self, channel, user, when): + await self._ready.wait() + if user.bot or not hasattr(channel, "guild") or not channel.guild: + return + net = await self._net_for_ch(channel.id) + if not net: return + nd = await self._net(net) + if not nd or not nd.get("sync_typing"): return + for ch_id in nd["channels"]: + if ch_id == channel.id: continue + ch = self.bot.get_channel(ch_id) + if ch: + try: await ch.typing() + except: pass + + # ── Pin sync ──────────────────────────────────────────────────────────── + + @commands.Cog.listener() + async def on_guild_channel_pins_update(self, channel, last_pin): + await self._ready.wait() + if not hasattr(channel, "guild"): return + net = await self._net_for_ch(channel.id) + if not net: return + nd = await self._net(net) + if not nd or not nd.get("sync_pins"): return + try: + pins = await channel.pins() + if not pins: return + latest = pins[0] + mapping = self.msg_map.get_relayed(net, latest.id) + if not mapping: return + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + msg = await ch.fetch_message(mid) + await msg.pin() + except: pass + except: pass + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # HELP + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="help") + async def wh_help(self, ctx): + """Full command reference.""" + p = ctx.clean_prefix + e1 = discord.Embed(title="🌀 Wormhole v3.4.0 — Commands (1/4)", colour=COLOUR_NEUTRAL, + description="The ultimate cross-server relay: hybrid slash+prefix commands, context menus, webhooks, DMs, embeds, starboard, auto-mod, invites, karma, portals, highlights, search & more.") + e1.add_field(name="📡 Networks", value=( + f"`{p}wh create/delete/open/close/list/info`\n" + f"`{p}wh discover` — public networks\n" + f"`{p}wh transfer @user`\n" + f"`{p}wh announce ` — broadcast\n" + f"`{p}wh portal [name]` — status embed\n" + f"`{p}wh search `\n" + f"`{p}wh schedule `\n" + f"`{p}wh rules/motd ` — display"), inline=False) + e1.add_field(name="⚙️ Settings (`wh set`)", value=( + f"`relay-mode` `webhooks` `name-mode` `image-mode` `custom-icon` `custom-name`\n" + f"`description` `colour` `ratelimit` `slowmode` `relay-delay` `log-channel` `nickname`\n" + f"`welcome` `motd` `rules` `tags` `public` `max-filesize` `blocked-extensions`\n" + f"`freeze` `silent` `nsfw-gate` `anonymous` `ephemeral` `media-only`\n" + f"`sync-edits/deletes/reactions/replies/stickers/threads/pins/typing`\n" + f"`forward-embeds` `strip-everyone/roles/users` `channel-override`"), inline=False) + + e2 = discord.Embed(title="🌀 Commands (2/4)", colour=COLOUR_NEUTRAL) + e2.add_field(name="📧 DM Relay (`wh dm`)", value=( + f"`enable/disable `\n" + f"`mode `\n" + f"`subscribe/unsubscribe `\n" + f"`send ` — send from DM\n" + f"`list` — your subscriptions"), inline=True) + e2.add_field(name="⭐ Staff", value=f"`{p}wh staff add/remove/list`", inline=True) + e2.add_field(name="🔗 Invites (`wh invite`)", value=( + f"`create [max] [min]`\n" + f"`vanity `\n" + f"`use ` — join\n" + f"`revoke/list`"), inline=True) + e2.add_field(name="🛡️ Mod (`wh mod`)", value=( + f"`ban/unban/mute/unmute @user`\n" + f"`ban-server/unban-server/mute-server/unmute-server`\n" + f"`allowlist-add/remove` `purge` `edit` `nuke`"), inline=False) + e2.add_field(name="🤖 Auto-Mod (`wh automod`)", value=( + f"`enable/disable` `anti-spam/mentions/caps/invite/link`\n" + f"`anti-zalgo/spoiler/emote-spam/newlines/raid` `status`"), inline=False) + + e3 = discord.Embed(title="🌀 Commands (3/4)", colour=COLOUR_NEUTRAL) + e3.add_field(name="🔍 Filters", value="`add-word/remove-word` `add-regex/remove-regex` `list`", inline=True) + e3.add_field(name="⭐ Starboard", value=f"`{p}wh starboard enable #ch [threshold]`\n`disable`", inline=True) + e3.add_field(name="💎 Karma", value=f"`{p}wh karma enable/disable/check/leaderboard`", inline=True) + e3.add_field(name="🔔 Highlights", value=f"`{p}wh highlight add/remove/list `", inline=True) + e3.add_field(name="🌙 Blackout", value=f"`{p}wh blackout add/clear/list`", inline=True) + e3.add_field(name="💾 Backup", value=f"`{p}wh backup/restore`", inline=True) + e3.add_field(name="👤 Profiles", value=f"`{p}wh profile [@user]`", inline=True) + e3.add_field(name="📋 Audit", value=f"`{p}wh audit [count]`", inline=True) + e3.add_field(name="🌐 Global", value=f"`{p}wh global ban-user/unban-user/ban-server/unban-server/list`", inline=True) + e3.add_field(name="📊 Phase 4", value=( + f"`mirror add/remove/list` `poll create/close/list` `afk`\n" + f"`ignore add/remove/list` `autoreply add/remove/list`\n" + f"`bookmark save/list/clear` `colour` `quiet/quiet-off`\n" + f"`analytics` `health` `bridge add/remove/list`"), inline=False) + + e4 = discord.Embed(title="🌀 Commands (4/4) — Phase 5 & Slash", colour=COLOUR_NEUTRAL) + e4.add_field(name="🔔 Mentions (`wh mentions`)", value=( + f"`set `\n" + f"`server-set ` — per-server override\n" + f"`exempt/unexempt @user` — bypass policy\n" + f"`status ` — view current policy"), inline=False) + e4.add_field(name="📜 Terms of Service (`wh tos`)", value=( + f"`enable/disable ` — require acceptance\n" + f"`set ` — custom ToS text\n" + f"`template ` — reset to legal template\n" + f"`accepted ` — who accepted\n" + f"`reset ` — force re-acceptance\n" + f"`{p}wh accept/agree ` — accept ToS"), inline=False) + e4.add_field(name="🚨 Reports (`wh report`)", value=( + f"`message [msg_id] [reason]` — report a message\n" + f"`list [show_resolved]` — view reports\n" + f"`resolve ` — resolve a report\n" + f"`action `"), inline=False) + e4.add_field(name="📱 Context Menus (right-click)", value=( + "• **Report to Wormhole** — report a message\n" + "• **Wormhole Bookmark** — save a message\n" + "• **Wormhole Delete** — delete from network (staff)\n" + "• **Wormhole Profile** — view user profile"), inline=False) + e4.add_field(name="⚡ Slash Commands", value=( + "`/wormhole-list` `/wormhole-info` `/wormhole-discover`\n" + "`/wormhole-accept` `/wormhole-agree` `/wormhole-report`\n" + "`/wormhole-profile`\n" + "*All other commands available via prefix: `[p]wh`*"), inline=False) + e4.set_footer(text="Wormhole v3.4.0 • Slash commands + prefix + context menus • EveCogs") + + await ctx.send(embeds=[e1, e2, e3, e4]) + + +async def setup(bot: Red): + """Required setup function for Red-DiscordBot cog loading.""" + await bot.add_cog(Wormhole(bot))