EveCogs/wormhole/wormhole.py
everestmcarthur 680b87db39 v3.2.0 — Phase 3: DM relay, starboard, karma, auto-mod, highlights, search & more
Complete ground-up rewrite (2,762 lines across 3 files):

Phase 3 features:
- Bidirectional DM relay (subscribe, send, receive, 3 formats)
- Cross-network starboard with configurable threshold
- Reputation/karma system with leaderboard
- Keyword highlight notifications (DM alerts)
- Anti-raid detection with auto-freeze
- Attachment filters (blocked extensions, max filesize)
- Pin sync across channels
- Typing indicator relay
- Network discovery (public flag + tags)
- Message search across network
- Scheduled messages (timed broadcasts)
- Network-wide slowmode
- Relay delay (configurable)
- Vanity invite codes
- Per-channel setting overrides
- Purge command (mass delete relayed)
- Audit log (500 entries, full trail)
- MOTD and rules
- Comprehensive README with full command reference

Phase 2 features (carried forward):
- Auto-moderation engine (spam, caps, invites, links, zalgo, spoiler, emote/newline spam)
- Invite system with expiry and max uses
- Staff announcements and portal messages
- Welcome messages and mention control
- User profiles and network stats
- Backup/restore (JSON export/import)
- Blackout scheduling
- Global blocklist (bot owner)
- Embed and compact relay modes

Phase 1 features (carried forward):
- Named networks with webhook relay
- Edit/delete/reply/reaction/sticker sync
- Staff system with ownership transfer
- Word and regex content filters
- Rate limiting and NSFW gate
- Server nicknames, custom identity
- Logging channel
2026-05-07 22:19:09 +00:00

2347 lines
122 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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.2.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
"""
from __future__ import annotations
import asyncio
import io
import json
import logging
import re
import time
from collections import defaultdict
from copy import deepcopy
from datetime import datetime, timezone
from typing import Dict, List, Optional, Set
import discord
from redbot.core import Config, checks, commands
from redbot.core.bot import Red
from .utils import (
BLOCKED_EXTENSIONS,
COLOUR_ANNOUNCE,
COLOUR_DM,
COLOUR_INFO,
COLOUR_NEUTRAL,
COLOUR_OK,
COLOUR_STAR,
CooldownBucket,
DuplicateDetector,
RaidDetector,
announce_embed,
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,
}
_DEFAULT_GLOBAL = {
"networks": {},
"max_networks_per_user": 10,
"global_banned_users": [],
"global_banned_servers": [],
}
_MAP_LIMIT = 2_000
_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]:
return self.forward.get(network, {}).get(original_id, {})
def get_original(self, network: str, relayed_id: int) -> Optional[int]:
return self.reverse.get(network, {}).get(relayed_id)
def get_all_relayed_ids(self, network: str, original_id: int) -> List[int]:
return list(self.forward.get(network, {}).get(original_id, {}).values())
class Wormhole(commands.Cog):
"""The ultimate cross-server relay: networks, DMs, starboard, auto-mod, invites, portals & more."""
__version__ = "3.2.0"
def __init__(self, bot: Red):
self.bot = bot
self.config = Config.get_conf(self, identifier=7730187301, force_registration=True)
self.config.register_global(**_DEFAULT_GLOBAL)
self.webhook_cache: Dict[int, discord.Webhook] = {}
self.cooldowns: Dict[str, CooldownBucket] = {}
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._ready = asyncio.Event()
self._bg_tasks: List[asyncio.Task] = []
self._startup_task = self.bot.loop.create_task(self._init())
async def _init(self):
await self.bot.wait_until_ready()
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(self.bot.loop.create_task(self._blackout_loop()))
self._bg_tasks.append(self.bot.loop.create_task(self._portal_update_loop()))
self._bg_tasks.append(self.bot.loop.create_task(self._scheduled_msg_loop()))
self._ready.set()
log.info("Wormhole v3.2.0 ready — %d networks loaded.", len(networks))
async def cog_unload(self):
self._startup_task.cancel()
for t in self._bg_tasks:
t.cancel()
# ── 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
elif not should and data.get("frozen"):
networks[name]["frozen"] = False
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)
# ── 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) -> discord.Webhook:
if ch.id in self.webhook_cache:
return self.webhook_cache[ch.id]
try:
for w in await ch.webhooks():
if w.user == self.bot.user 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:
raise commands.UserFeedbackCheckFailure("Missing webhook permissions.")
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
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")
for uid in subs:
if uid == message.author.id:
continue
user = self.bot.get_user(uid)
if not user:
continue
try:
nick = net_data.get("server_nicknames", {}).get(str(message.guild.id))
if dm_mode == "embed":
em = build_dm_incoming_embed(
message.author.display_name,
message.author.display_avatar.url,
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}] {message.author.display_name}:** {truncate(message.content, 1800)}"
await user.send(text)
else:
await user.send(f"**{message.author.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]} • v3.2.0")
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}"))
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 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
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:
return
if is_thread and not nd.get("sync_threads"): return
if nd.get("frozen"): return
# Ignore commands
pfx = await self.bot.get_prefix(message)
if isinstance(pfx, str): pfx = [pfx]
if any(message.content.startswith(p) for p in pfx): return
# NSFW gate
if nd.get("nsfw_gate") and hasattr(message.channel, "is_nsfw") and message.channel.is_nsfw(): return
# Global blocklist
if message.author.id in await self.config.global_banned_users(): return
if message.guild.id in await self.config.global_banned_servers(): return
# Per-network checks
if message.author.id in nd.get("banned_users", []): return
if message.author.id in nd.get("muted_users", []): return
if message.guild.id in nd.get("banned_servers", []): return
if message.guild.id in nd.get("muted_servers", []): 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))
# Build payload
relay_mode = nd.get("relay_mode", "webhook")
nick = nd.get("server_nicknames", {}).get(str(message.guild.id))
mc = nd.get("mention_control", {})
content = sanitise_mentions(message.content or "", mc)
# Reply context
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)
preview = truncate(ref.content, 100) if ref.content else "*[attachment]*"
content = f"> **↩ {ref.author.display_name}:** {preview}\n{content}"
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"]
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)
# Relay to channels
mapping: Dict[int, int] = {}
for ch_id in nd["channels"]:
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
try:
if ch_mode == "webhook":
wh = await self._wh(ch)
files = []
for a in message.attachments:
try: files.append(await a.to_file())
except: pass
sent = await wh.send(content=content or None, username=truncate(uname, 80), avatar_url=avatar,
files=files or discord.utils.MISSING, embeds=extra_embeds or discord.utils.MISSING, wait=True)
mapping[ch_id] = sent.id
elif ch_mode == "embed":
em = build_relay_embed(message, nick, nd.get("colour"))
sent = await ch.send(embeds=[em] + extra_embeds[:9])
mapping[ch_id] = sent.id
elif ch_mode == "compact":
g = nick or message.guild.name
files = []
for a in message.attachments:
try: files.append(await a.to_file())
except: pass
sent = await ch.send(content=compact_format(g, message.author.display_name, content), files=files or None)
mapping[ch_id] = sent.id
except Exception as exc:
log.warning("Relay fail %s: %s", ch_id, exc)
if mapping:
self.msg_map.add(net_name, message.id, mapping)
# Stats + profile
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)
# 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.2.0 — Commands (1/3)", colour=COLOUR_NEUTRAL,
description="The ultimate cross-server relay: 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/stats`\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` `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/3)", 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 <name> [n]`"), 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/3)", 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.set_footer(text="Wormhole v3.2.0 • EveCogs • github.com/everestmcarthur/EveCogs")
await ctx.send(embeds=[e1, e2, e3])