EveCogs/wormhole/wormhole.py
Viktor 2acee87123 fix(wormhole): resolve reply button to local copy, not original server
The forward map only stores relay targets — the source channel (where
the original message was typed) was never included. When someone in
Server B replied to a relayed message, the button for Server A fell
back to the fallback URL pointing at Server B instead of Server A.

Fix: after building the relay-target URLs, identify the source channel
by finding network channels not present in the forward map, and map
them to the original message ID.
2026-05-14 15:23:27 +00:00

4685 lines
246 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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.
"""
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
_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.3"
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: <msgs> per <seconds>."""
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} <message>`"))
@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 <name> 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"]
# ── 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 {}
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
send_content = content if content else None
if not send_content and not files and not extra_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=extra_embeds 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
send_content = content if content else None
if not send_content and not files2 and not extra_embeds:
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=extra_embeds 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"))
sent_msg = await ch.send(embeds=[em] + extra_embeds[:9], **_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
sent_msg = await ch.send(content=compact_format(g, display, content), files=files 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 <name> @user`\n"
f"`{p}wh announce <name> <msg>` — broadcast\n"
f"`{p}wh portal [name]` — status embed\n"
f"`{p}wh search <name> <query>`\n"
f"`{p}wh schedule <name> <min> <msg>`\n"
f"`{p}wh rules/motd <name>` — 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 <name>`\n"
f"`mode <name> <embed|compact|plain>`\n"
f"`subscribe/unsubscribe <name>`\n"
f"`send <name> <message>` — 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 <name> [max] [min]`\n"
f"`vanity <name> <word>`\n"
f"`use <code>` — join\n"
f"`revoke/list`"), inline=True)
e2.add_field(name="🛡️ Mod (`wh mod`)", value=(
f"`ban/unban/mute/unmute <name> @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 <name> #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 <name> <keyword>`", 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 <name> [@user]`", inline=True)
e3.add_field(name="📋 Audit", value=f"`{p}wh audit <name> [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 <name> <users|roles|everyone|here> <bool>`\n"
f"`server-set <name> <type> <bool>` — per-server override\n"
f"`exempt/unexempt <name> @user` — bypass policy\n"
f"`status <name>` — view current policy"), inline=False)
e4.add_field(name="📜 Terms of Service (`wh tos`)", value=(
f"`enable/disable <name>` — require acceptance\n"
f"`set <name> <text>` — custom ToS text\n"
f"`template <name>` — reset to legal template\n"
f"`accepted <name>` — who accepted\n"
f"`reset <name>` — force re-acceptance\n"
f"`{p}wh accept/agree <name>` — accept ToS"), inline=False)
e4.add_field(name="🚨 Reports (`wh report`)", value=(
f"`message [msg_id] [reason]` — report a message\n"
f"`list <name> [show_resolved]` — view reports\n"
f"`resolve <name> <id>` — resolve a report\n"
f"`action <name> <id> <ban|mute|warn|dismiss>`"), 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])