refactor: Wormhole v4.0.0 — full modular architecture with hierarchical staff

BREAKING: Complete cog restructure from monolithic wormhole.py into modular architecture.

## Architecture
- models/permissions.py — Role IntEnum (MEMBER→OWNER), requires_role() decorator
- models/config.py — DEFAULT_NETWORK/DEFAULT_GLOBAL with 80+ config keys
- models/message_map.py — MessageMap class (extracted from _MessageMap)
- services/emoji.py — Foreign emoji resolution and relay
- ui/views.py — Reply jump button view
- ui/modals.py — Report modal
- core.py — Main Wormhole class, helpers, background loops, context menus
- commands/ — 13 command modules as mixin classes
- listeners/ — relay pipeline, edit/delete/reaction sync, typing/pin sync

## Staff System (new)
Per-network hierarchical roles replacing flat owner/staff:
  Bot Owner (Red built-in) — global override
  └─ Network Owner → Admin → Moderator → Helper → Member
  Each role assigned per-network. Higher roles can assign lower.
  Legacy staff_ids auto-migrated to ADMIN role.

## Enhanced Moderation
- Server-level ban/mute (not just user-level)
- Allowlist mode (only whitelisted servers can send)
- Mod edit and nuke (delete N messages by user)
- Anti-raid detector with auto-freeze
- Expanded automod: anti-zalgo, anti-emote-spam, anti-newline-spam

## Commands (100+)
network, settings, staff, moderation, filters, social, dm, mentions,
tos, reports, bridge, advanced (polls/afk/auto-responses/scheduled/
analytics/quiet-hours/colours/invites/announcements/blackout/portal),
debug (health/trace/testsend/relaydebug/backup/restore/version)

## Files
- 30 Python files, 5,274 lines total (was 4,782 in monolith)
- Original wormhole.py preserved for reference
- utils.py unchanged (all helpers reused)
This commit is contained in:
Viktor AI 2026-05-14 19:07:49 +00:00
parent f48f755223
commit 0b028e7bb9
30 changed files with 4828 additions and 26 deletions

View file

@ -1,6 +1,6 @@
"""Wormhole — the ultimate cross-server relay cog for Red-DiscordBot."""
"""Wormhole v4.0.0 — the ultimate cross-server relay cog for Red-DiscordBot."""
from .wormhole import Wormhole
from .core import Wormhole
__red_end_user_data_statement__ = (
"This cog stores Discord user IDs, guild IDs, and channel IDs for relay functionality. "
@ -12,3 +12,4 @@ __red_end_user_data_statement__ = (
async def setup(bot):
cog = Wormhole(bot)
await bot.add_cog(cog)
await cog._init()

View file

@ -0,0 +1,31 @@
"""Command modules — each is a mixin class inherited by the main Wormhole cog."""
from .advanced import AdvancedCommands
from .bridge import BridgeCommands
from .debug import DebugCommands
from .dm import DMCommands
from .filters import FilterCommands
from .mentions import MentionCommands
from .moderation import ModerationCommands
from .network import NetworkCommands
from .reports import ReportCommands
from .settings import SettingsCommands
from .social import SocialCommands
from .staff import StaffCommands
from .tos import ToSCommands
__all__ = [
"AdvancedCommands",
"BridgeCommands",
"DebugCommands",
"DMCommands",
"FilterCommands",
"MentionCommands",
"ModerationCommands",
"NetworkCommands",
"ReportCommands",
"SettingsCommands",
"SocialCommands",
"StaffCommands",
"ToSCommands",
]

View file

@ -0,0 +1,435 @@
"""Advanced commands — polls, AFK, auto-responses, scheduled messages,
analytics, ephemeral, quiet hours, user colours, invites, announcements."""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
import discord
from redbot.core import commands
from ..models.permissions import Role, requires_role, has_role
from ..utils import (
ok_embed, err_embed, info_embed, warn_embed, announce_embed,
truncate, generate_invite_code, human_timedelta, COLOUR_INFO,
)
class AdvancedCommands:
"""Mixin — advanced features."""
# ── Polls ──────────────────────────────────────────────────────────────
@commands.group(name="wh-poll", aliases=["whpoll"], invoke_without_command=True)
async def wh_poll(self, ctx: commands.Context) -> None:
"""Network-wide polls."""
await ctx.send_help(ctx.command)
@wh_poll.command(name="create")
@requires_role(Role.MODERATOR)
async def wh_poll_create(self, ctx: commands.Context, name: str, duration_minutes: int, question: str, *, options: str) -> None:
"""Create a poll. Options separated by ``|``.
Example: ``wh poll create mynet 60 Favourite colour? Red | Blue | Green``
"""
option_list = [o.strip() for o in options.split("|") if o.strip()]
if len(option_list) < 2:
return await ctx.send(embed=err_embed("Need at least 2 options separated by `|`."))
if len(option_list) > 10:
return await ctx.send(embed=err_embed("Maximum 10 options."))
expires = (datetime.now(timezone.utc) + timedelta(minutes=duration_minutes)).isoformat()
poll_id = secrets.token_hex(4)
poll = {
"question": question,
"options": option_list,
"votes": {},
"expires": expires,
"creator": ctx.author.id,
}
async with self.config.networks() as ns:
ns[name].setdefault("active_polls", {})[poll_id] = poll
emojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"]
desc = "\n".join(f"{emojis[i]} {opt}" for i, opt in enumerate(option_list))
desc += f"\n\nVote with `wh poll vote {name} {poll_id} <number>`\nExpires in {duration_minutes} minutes."
em = discord.Embed(title=f"📊 {question}", description=desc, colour=COLOUR_INFO)
nd = await self._net(name)
for ch_id in nd.get("channels", []):
ch = self.bot.get_channel(ch_id)
if ch:
try:
await ch.send(embed=em)
except Exception:
pass
await ctx.send(embed=ok_embed(f"Poll `{poll_id}` created in `{name}`."))
@wh_poll.command(name="vote")
async def wh_poll_vote(self, ctx: commands.Context, name: str, poll_id: str, choice: int) -> None:
"""Vote in a poll (choice = option number, 1-based)."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
poll = nd.get("active_polls", {}).get(poll_id)
if not poll:
return await ctx.send(embed=err_embed("Poll not found or expired."))
if choice < 1 or choice > len(poll["options"]):
return await ctx.send(embed=err_embed(f"Choose 1–{len(poll['options'])}."))
idx = str(choice - 1)
uid = ctx.author.id
async with self.config.networks() as ns:
p = ns[name]["active_polls"][poll_id]
# Remove previous vote
for k, voters in p.get("votes", {}).items():
if uid in voters:
voters.remove(uid)
p.setdefault("votes", {}).setdefault(idx, []).append(uid)
await ctx.send(embed=ok_embed(f"Voted for **{poll['options'][choice-1]}**."), delete_after=5)
@wh_poll.command(name="results")
async def wh_poll_results(self, ctx: commands.Context, name: str, poll_id: str) -> None:
"""View current poll results."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
poll = nd.get("active_polls", {}).get(poll_id)
if not poll:
return await ctx.send(embed=err_embed("Poll not found."))
results = self._format_poll_results(poll)
await ctx.send(embed=info_embed(results, title=f"📊 {poll['question']}"))
@wh_poll.command(name="end")
@requires_role(Role.MODERATOR)
async def wh_poll_end(self, ctx: commands.Context, name: str, poll_id: str) -> None:
"""End a poll early."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
poll = nd.get("active_polls", {}).get(poll_id)
if not poll:
return await ctx.send(embed=err_embed("Poll not found."))
results = self._format_poll_results(poll)
async with self.config.networks() as ns:
ns[name].get("active_polls", {}).pop(poll_id, None)
for ch_id in nd.get("channels", []):
ch = self.bot.get_channel(ch_id)
if ch:
try:
await ch.send(embed=info_embed(results, title=f"📊 Poll Ended: {poll['question']}"))
except Exception:
pass
await ctx.send(embed=ok_embed("Poll ended."))
# ── AFK ────────────────────────────────────────────────────────────────
@commands.hybrid_command(name="wh-afk")
async def wh_afk(self, ctx: commands.Context, name: str, *, reason: str = "AFK") -> None:
"""Set your AFK status in a network."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
async with self.config.networks() as ns:
ns[name].setdefault("afk_users", {})[str(ctx.author.id)] = {
"reason": reason,
"since_iso": datetime.now(timezone.utc).isoformat(),
}
await ctx.send(embed=ok_embed(f"AFK set: **{reason}**"))
# ── Auto-responses ─────────────────────────────────────────────────────
@commands.group(name="wh-ar", aliases=["whar"], invoke_without_command=True)
async def wh_ar(self, ctx: commands.Context) -> None:
"""Auto-response triggers."""
await ctx.send_help(ctx.command)
@wh_ar.command(name="add")
@requires_role(Role.ADMIN)
async def wh_ar_add(self, ctx: commands.Context, name: str, trigger: str, *, reply: str) -> None:
"""Add an auto-response. Use ``regex:pattern`` for regex triggers."""
is_regex = trigger.startswith("regex:")
if is_regex:
trigger = trigger[6:]
import re
try:
re.compile(trigger)
except re.error as e:
return await ctx.send(embed=err_embed(f"Invalid regex: {e}"))
async with self.config.networks() as ns:
ns[name].setdefault("auto_responses", {})[trigger] = {
"reply": reply,
"regex": is_regex,
"cooldown": 30,
"last_used": 0,
}
await ctx.send(embed=ok_embed(f"Auto-response added for `{trigger}`."))
@wh_ar.command(name="remove", aliases=["rm"])
@requires_role(Role.ADMIN)
async def wh_ar_rm(self, ctx: commands.Context, name: str, *, trigger: str) -> None:
"""Remove an auto-response."""
async with self.config.networks() as ns:
ns[name].get("auto_responses", {}).pop(trigger, None)
await ctx.send(embed=ok_embed(f"Auto-response removed for `{trigger}`."))
@wh_ar.command(name="list")
@requires_role(Role.HELPER)
async def wh_ar_list(self, ctx: commands.Context, name: str) -> None:
"""List auto-responses."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
ars = nd.get("auto_responses", {})
if not ars:
return await ctx.send(embed=info_embed("No auto-responses configured."))
lines = []
for trigger, cfg in ars.items():
kind = "regex" if cfg.get("regex") else "text"
lines.append(f"• `{trigger}` ({kind}) → {truncate(cfg.get('reply', ''), 60)}")
await ctx.send(embed=info_embed("\n".join(lines), title=f"🤖 Auto-Responses — {name}"))
# ── Scheduled messages ─────────────────────────────────────────────────
@commands.group(name="wh-schedule", aliases=["whschedule"], invoke_without_command=True)
async def wh_schedule(self, ctx: commands.Context) -> None:
"""Scheduled messages."""
await ctx.send_help(ctx.command)
@wh_schedule.command(name="add")
@requires_role(Role.ADMIN)
async def wh_schedule_add(self, ctx: commands.Context, name: str, minutes_from_now: int, *, content: str) -> None:
"""Schedule a message to be sent in N minutes."""
send_at = (datetime.now(timezone.utc) + timedelta(minutes=minutes_from_now)).isoformat()
async with self.config.networks() as ns:
ns[name].setdefault("scheduled_messages", []).append({
"content": content,
"send_at_iso": send_at,
"creator": ctx.author.id,
})
await ctx.send(embed=ok_embed(f"Message scheduled for {minutes_from_now} minutes from now."))
@wh_schedule.command(name="list")
@requires_role(Role.HELPER)
async def wh_schedule_list(self, ctx: commands.Context, name: str) -> None:
"""List scheduled messages."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
msgs = nd.get("scheduled_messages", [])
if not msgs:
return await ctx.send(embed=info_embed("No scheduled messages."))
lines = []
for i, sm in enumerate(msgs, 1):
lines.append(f"**{i}.** {sm.get('send_at_iso', '?')[:16]} — {truncate(sm.get('content', ''), 60)}")
await ctx.send(embed=info_embed("\n".join(lines), title=f"📅 Scheduled — {name}"))
@wh_schedule.command(name="clear")
@requires_role(Role.ADMIN)
async def wh_schedule_clear(self, ctx: commands.Context, name: str) -> None:
"""Clear all scheduled messages."""
async with self.config.networks() as ns:
ns[name]["scheduled_messages"] = []
await ctx.send(embed=ok_embed("All scheduled messages cleared."))
# ── Analytics ──────────────────────────────────────────────────────────
@commands.hybrid_command(name="wh-analytics")
@requires_role(Role.HELPER)
async def wh_analytics(self, ctx: commands.Context, name: str) -> None:
"""View network analytics dashboard."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
analytics = nd.get("analytics", {})
hourly = analytics.get("hourly", {})
top_users = analytics.get("top_users", {})
em = discord.Embed(title=f"📈 Analytics — {name}", colour=COLOUR_INFO)
em.add_field(name="Total Messages", value=f"{nd.get('total_messages', 0):,}", inline=True)
em.add_field(name="Channels", value=str(len(nd.get("channels", []))), inline=True)
# Last 24h
now = datetime.now(timezone.utc)
last_24h = sum(v for k, v in hourly.items() if k >= (now - timedelta(hours=24)).strftime("%Y-%m-%d-%H"))
last_7d = sum(hourly.values())
em.add_field(name="Last 24h", value=f"{last_24h:,}", inline=True)
em.add_field(name="Last 7d", value=f"{last_7d:,}", inline=True)
# Top users
if top_users:
sorted_users = sorted(top_users.items(), key=lambda x: x[1], reverse=True)[:5]
top_lines = []
for uid_str, count in sorted_users:
user = self.bot.get_user(int(uid_str))
top_lines.append(f"**{user or uid_str}** — {count:,}")
em.add_field(name="Top Users (all time)", value="\n".join(top_lines), inline=False)
await ctx.send(embed=em)
# ── Quiet hours ────────────────────────────────────────────────────────
@commands.hybrid_command(name="wh-quiet")
async def wh_quiet(self, ctx: commands.Context, name: str, start_hour: int, end_hour: int, tz_offset: int = 0) -> None:
"""Set quiet hours — DM relay pauses during this window.
Hours are 0-23. ``tz_offset`` is hours from UTC.
"""
if not (0 <= start_hour <= 23 and 0 <= end_hour <= 23):
return await ctx.send(embed=err_embed("Hours must be 0–23."))
async with self.config.networks() as ns:
ns[name].setdefault("quiet_hours", {})[str(ctx.author.id)] = {
"start_hour": start_hour,
"end_hour": end_hour,
"tz_offset": tz_offset,
}
await ctx.send(embed=ok_embed(f"Quiet hours set: {start_hour}:00–{end_hour}:00 (UTC{tz_offset:+d})"))
@commands.hybrid_command(name="wh-unquiet")
async def wh_unquiet(self, ctx: commands.Context, name: str) -> None:
"""Remove your quiet hours."""
async with self.config.networks() as ns:
ns[name].get("quiet_hours", {}).pop(str(ctx.author.id), None)
await ctx.send(embed=ok_embed("Quiet hours removed."))
# ── User colours ───────────────────────────────────────────────────────
@commands.hybrid_command(name="wh-colour", aliases=["wh-color"])
async def wh_colour(self, ctx: commands.Context, name: str, hex_code: str = None) -> None:
"""Set your name colour in embed mode (hex, e.g. ``#ff5733``)."""
if hex_code:
try:
int(hex_code.lstrip("#"), 16)
except ValueError:
return await ctx.send(embed=err_embed("Invalid hex colour."))
async with self.config.networks() as ns:
if hex_code:
ns[name].setdefault("user_colours", {})[str(ctx.author.id)] = hex_code.lstrip("#")
else:
ns[name].get("user_colours", {}).pop(str(ctx.author.id), None)
await ctx.send(embed=ok_embed(f"Colour {'set' if hex_code else 'cleared'}."))
# ── Invites ────────────────────────────────────────────────────────────
@commands.group(name="wh-invite", aliases=["whinvite"], invoke_without_command=True)
async def wh_invite(self, ctx: commands.Context) -> None:
"""Network invite codes."""
await ctx.send_help(ctx.command)
@wh_invite.command(name="create")
@requires_role(Role.ADMIN)
async def wh_invite_create(self, ctx: commands.Context, name: str, max_uses: int = 0) -> None:
"""Create an invite code (``max_uses`` 0 = unlimited)."""
code = generate_invite_code()
async with self.config.networks() as ns:
ns[name].setdefault("invites", {})[code] = {
"creator": ctx.author.id,
"max_uses": max_uses,
"uses": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
await ctx.send(embed=ok_embed(f"Invite code: `{code}`\nMax uses: {'unlimited' if max_uses == 0 else max_uses}"))
@wh_invite.command(name="vanity")
@requires_role(Role.OWNER)
async def wh_invite_vanity(self, ctx: commands.Context, name: str, code: str) -> None:
"""Set a vanity invite code."""
if len(code) < 3 or len(code) > 20:
return await ctx.send(embed=err_embed("Code must be 3–20 characters."))
async with self.config.networks() as ns:
ns[name]["vanity_invite"] = code
await ctx.send(embed=ok_embed(f"Vanity invite set: `{code}`"))
@wh_invite.command(name="list")
@requires_role(Role.ADMIN)
async def wh_invite_list(self, ctx: commands.Context, name: str) -> None:
"""List all invite codes."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
invites = nd.get("invites", {})
if not invites:
return await ctx.send(embed=info_embed("No invites."))
lines = []
for code, data in invites.items():
uses = data.get("uses", 0)
mx = data.get("max_uses", 0)
lines.append(f"`{code}` — {uses}/{mx if mx else '∞'} uses")
vanity = nd.get("vanity_invite")
if vanity:
lines.append(f"\n✨ Vanity: `{vanity}`")
await ctx.send(embed=info_embed("\n".join(lines), title=f"🔗 Invites — {name}"))
@wh_invite.command(name="revoke")
@requires_role(Role.ADMIN)
async def wh_invite_revoke(self, ctx: commands.Context, name: str, code: str) -> None:
"""Revoke an invite code."""
async with self.config.networks() as ns:
ns[name].get("invites", {}).pop(code, None)
await ctx.send(embed=ok_embed(f"Invite `{code}` revoked."))
# ── Announcements ──────────────────────────────────────────────────────
@commands.hybrid_command(name="wh-announce")
@requires_role(Role.ADMIN)
async def wh_announce(self, ctx: commands.Context, name: str, *, message: str) -> None:
"""Broadcast an announcement to all channels in the network."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
em = announce_embed(message, title=f"📢 {name} — Announcement")
sent = 0
for ch_id in nd.get("channels", []):
ch = self.bot.get_channel(ch_id)
if ch:
try:
await ch.send(embed=em)
sent += 1
except Exception:
pass
await ctx.send(embed=ok_embed(f"Announcement sent to {sent} channels."))
await self._audit(name, "announce", str(ctx.author), details=truncate(message, 100))
# ── Blackout schedules ─────────────────────────────────────────────────
@commands.group(name="wh-blackout", aliases=["whblackout"], invoke_without_command=True)
async def wh_blackout(self, ctx: commands.Context) -> None:
"""Scheduled freeze windows."""
await ctx.send_help(ctx.command)
@wh_blackout.command(name="add")
@requires_role(Role.ADMIN)
async def wh_blackout_add(self, ctx: commands.Context, name: str, start_hour: int, end_hour: int, *, days: str = "0,1,2,3,4,5,6") -> None:
"""Add a blackout schedule. Days: 0=Mon … 6=Sun."""
day_list = [int(d.strip()) for d in days.split(",") if d.strip().isdigit()]
async with self.config.networks() as ns:
ns[name].setdefault("blackout_schedules", []).append({
"start_hour": start_hour,
"end_hour": end_hour,
"days": day_list,
})
await ctx.send(embed=ok_embed(f"Blackout added: {start_hour}:00–{end_hour}:00 UTC on days {day_list}"))
@wh_blackout.command(name="clear")
@requires_role(Role.ADMIN)
async def wh_blackout_clear(self, ctx: commands.Context, name: str) -> None:
"""Clear all blackout schedules."""
async with self.config.networks() as ns:
ns[name]["blackout_schedules"] = []
await ctx.send(embed=ok_embed("All blackout schedules cleared."))
# ── Portal embed ───────────────────────────────────────────────────────
@commands.hybrid_command(name="wh-portal")
@requires_role(Role.ADMIN)
async def wh_portal(self, ctx: commands.Context, name: str) -> None:
"""Post a self-updating portal embed in this channel."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
from ..utils import build_portal_embed
em = build_portal_embed(name, nd, len(nd.get("channels", [])), nd.get("total_messages", 0))
msg = await ctx.send(embed=em)
async with self.config.networks() as ns:
ns[name].setdefault("portal_messages", {})[str(ctx.channel.id)] = msg.id
await ctx.send(embed=ok_embed("Portal embed posted. It will auto-update every 5 minutes."), delete_after=10)

111
wormhole/commands/bridge.py Normal file
View file

@ -0,0 +1,111 @@
"""Bridge commands — cross-network bridging and mirror channels."""
from __future__ import annotations
import discord
from redbot.core import commands
from ..models.permissions import Role, requires_role
from ..utils import ok_embed, err_embed, info_embed, COLOUR_INFO
class BridgeCommands:
"""Mixin — network bridging and mirror channels."""
@commands.group(name="wh-bridge", aliases=["whbridge"], invoke_without_command=True)
async def wh_bridge(self, ctx: commands.Context) -> None:
"""Cross-network bridging."""
await ctx.send_help(ctx.command)
@wh_bridge.command(name="link")
@requires_role(Role.OWNER)
async def wh_bridge_link(self, ctx: commands.Context, source: str, target: str) -> None:
"""Bridge messages from one network to another (one-way)."""
nd_src = await self._net(source)
nd_tgt = await self._net(target)
if not nd_src:
return await ctx.send(embed=err_embed(f"Network `{source}` not found."))
if not nd_tgt:
return await ctx.send(embed=err_embed(f"Network `{target}` not found."))
async with self.config.networks() as ns:
bridge_to = ns[source].setdefault("bridge_to", [])
if target not in bridge_to:
bridge_to.append(target)
bridge_from = ns[target].setdefault("bridge_from", [])
if source not in bridge_from:
bridge_from.append(source)
await ctx.send(embed=ok_embed(f"Bridge: `{source}` → `{target}` (one-way)."))
await self._audit(source, "bridge_link", str(ctx.author), target)
@wh_bridge.command(name="unlink")
@requires_role(Role.OWNER)
async def wh_bridge_unlink(self, ctx: commands.Context, source: str, target: str) -> None:
"""Remove a bridge."""
async with self.config.networks() as ns:
if source in ns:
bt = ns[source].get("bridge_to", [])
if target in bt:
bt.remove(target)
if target in ns:
bf = ns[target].get("bridge_from", [])
if source in bf:
bf.remove(source)
await ctx.send(embed=ok_embed(f"Bridge `{source}` → `{target}` removed."))
await self._audit(source, "bridge_unlink", str(ctx.author), target)
@wh_bridge.command(name="list")
async def wh_bridge_list(self, ctx: commands.Context, name: str) -> None:
"""List bridges for a network."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
lines = []
for t in nd.get("bridge_to", []):
lines.append(f"➡️ `{name}` → `{t}`")
for f in nd.get("bridge_from", []):
lines.append(f"⬅️ `{f}` → `{name}`")
if not lines:
lines.append("No bridges configured.")
await ctx.send(embed=info_embed("\n".join(lines), title=f"🌉 Bridges — {name}"))
# ── Mirror channels ────────────────────────────────────────────────────
@commands.group(name="wh-mirror", aliases=["whmirror"], invoke_without_command=True)
async def wh_mirror(self, ctx: commands.Context) -> None:
"""Mirror channels — exact copy of relay to an extra channel."""
await ctx.send_help(ctx.command)
@wh_mirror.command(name="add")
@requires_role(Role.ADMIN)
async def wh_mirror_add(self, ctx: commands.Context, name: str, channel: discord.TextChannel) -> None:
"""Add a mirror channel."""
async with self.config.networks() as ns:
mirrors = ns[name].setdefault("mirror_channels", [])
if channel.id not in mirrors:
mirrors.append(channel.id)
await ctx.send(embed=ok_embed(f"{channel.mention} added as mirror for `{name}`."))
@wh_mirror.command(name="remove", aliases=["rm"])
@requires_role(Role.ADMIN)
async def wh_mirror_rm(self, ctx: commands.Context, name: str, channel: discord.TextChannel) -> None:
"""Remove a mirror channel."""
async with self.config.networks() as ns:
mirrors = ns[name].get("mirror_channels", [])
if channel.id in mirrors:
mirrors.remove(channel.id)
await ctx.send(embed=ok_embed(f"{channel.mention} removed from mirrors for `{name}`."))
@wh_mirror.command(name="list")
async def wh_mirror_list(self, ctx: commands.Context, name: str) -> None:
"""List mirror channels."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
mirrors = nd.get("mirror_channels", [])
if not mirrors:
return await ctx.send(embed=info_embed("No mirror channels."))
lines = []
for ch_id in mirrors:
ch = self.bot.get_channel(ch_id)
lines.append(f"• {ch.mention if ch else ch_id}")
await ctx.send(embed=info_embed("\n".join(lines), title=f"🪞 Mirrors — {name}"))

178
wormhole/commands/debug.py Normal file
View file

@ -0,0 +1,178 @@
"""Debug and diagnostic commands — health, trace, test, backup/restore."""
from __future__ import annotations
import json
from copy import deepcopy
from datetime import datetime, timezone
from typing import Optional
import discord
from redbot.core import checks, commands
from ..models.permissions import Role, has_role, requires_role
from ..utils import ok_embed, err_embed, info_embed, warn_embed, COLOUR_INFO, human_timedelta
class DebugCommands:
"""Mixin — debug, diagnostics, backup/restore."""
@commands.group(name="wh-debug", aliases=["whdebug"], invoke_without_command=True)
async def wh_debug(self, ctx: commands.Context) -> None:
"""Debug and diagnostic tools."""
await ctx.send_help(ctx.command)
@wh_debug.command(name="health")
@requires_role(Role.HELPER)
async def wh_debug_health(self, ctx: commands.Context, name: str) -> None:
"""Check network health — permissions, webhooks, channel status."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
channels = nd.get("channels", [])
lines = []
healthy = 0
for ch_id in channels:
ch = self.bot.get_channel(ch_id)
if not ch:
lines.append(f"❌ `{ch_id}` — channel not found")
continue
perms = ch.permissions_for(ch.guild.me)
issues = []
if not perms.send_messages:
issues.append("no send_messages")
if not perms.manage_webhooks:
issues.append("no manage_webhooks")
if not perms.embed_links:
issues.append("no embed_links")
if not perms.attach_files:
issues.append("no attach_files")
if not perms.read_message_history:
issues.append("no read_history")
if issues:
lines.append(f"⚠️ {ch.mention} — {', '.join(issues)}")
else:
lines.append(f"✅ {ch.mention}")
healthy += 1
em = discord.Embed(
title=f"🏥 Health — {name}",
description="\n".join(lines) or "No channels",
colour=COLOUR_INFO,
)
em.set_footer(text=f"{healthy}/{len(channels)} healthy · Last check: {nd.get('last_health_check', 'never')[:16]}")
await ctx.send(embed=em)
@wh_debug.command(name="trace")
@requires_role(Role.ADMIN)
async def wh_debug_trace(self, ctx: commands.Context) -> None:
"""Toggle trace mode for this channel — logs all relay decisions."""
if ctx.channel.id in self._trace_channels:
self._trace_channels.discard(ctx.channel.id)
await ctx.send(embed=ok_embed("Trace mode **disabled** for this channel."))
else:
self._trace_channels.add(ctx.channel.id)
await ctx.send(embed=ok_embed("Trace mode **enabled** — relay decisions will be logged here."))
@wh_debug.command(name="testsend")
@requires_role(Role.ADMIN)
async def wh_debug_testsend(self, ctx: commands.Context, name: str) -> None:
"""Send a test message through the relay."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
em = info_embed(
f"🧪 Test relay from {ctx.author} in {ctx.guild.name if ctx.guild else 'DM'}",
title=f"Test — {name}",
)
sent = 0
for ch_id in nd.get("channels", []):
if ch_id == ctx.channel.id:
continue
ch = self.bot.get_channel(ch_id)
if ch:
try:
await ch.send(embed=em)
sent += 1
except Exception as exc:
await ctx.send(embed=warn_embed(f"Failed to send to {ch.mention}: {exc}"))
await ctx.send(embed=ok_embed(f"Test message sent to {sent} channels."))
@wh_debug.command(name="relaydebug")
@requires_role(Role.ADMIN)
async def wh_debug_relaydebug(self, ctx: commands.Context, name: str) -> None:
"""Show detailed relay pipeline state for diagnostics."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
em = discord.Embed(title=f"🔬 Relay Debug — {name}", colour=COLOUR_INFO)
em.add_field(name="Relay Mode", value=nd.get("relay_mode", "webhook"), inline=True)
em.add_field(name="Frozen", value=str(nd.get("frozen", False)), inline=True)
em.add_field(name="Silent", value=str(nd.get("silent", False)), inline=True)
em.add_field(name="Channels", value=str(len(nd.get("channels", []))), inline=True)
em.add_field(name="Banned Users", value=str(len(nd.get("banned_users", []))), inline=True)
em.add_field(name="Muted Users", value=str(len(nd.get("muted_users", []))), inline=True)
em.add_field(name="Msg Map Size", value=str(len(self.msg_map.forward.get(name, {}))), inline=True)
em.add_field(name="WH Cache Size", value=str(len(self._wh_cache)), inline=True)
am = nd.get("automod", {})
active_am = [k.replace("anti_", "") for k, v in am.items() if k.startswith("anti_") and v]
em.add_field(name="Automod Active", value=", ".join(active_am) or "None", inline=False)
await ctx.send(embed=em)
# ── Backup / Restore ───────────────────────────────────────────────────
@wh_debug.command(name="backup")
@requires_role(Role.OWNER)
async def wh_debug_backup(self, ctx: commands.Context, name: str) -> None:
"""Export network config as a JSON file."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
data = deepcopy(nd)
# Sanitise sensitive bits
data.pop("audit_log", None)
data.pop("reports", None)
import io
buf = io.BytesIO(json.dumps(data, indent=2, default=str).encode())
buf.seek(0)
await ctx.send(
embed=ok_embed(f"Backup for `{name}`:"),
file=discord.File(buf, filename=f"wormhole_{name}_backup.json"),
)
@wh_debug.command(name="restore")
@checks.is_owner()
async def wh_debug_restore(self, ctx: commands.Context, name: str) -> None:
"""Restore a network from a JSON backup (attach file to message)."""
if not ctx.message.attachments:
return await ctx.send(embed=err_embed("Attach a backup JSON file."))
att = ctx.message.attachments[0]
if not att.filename.endswith(".json"):
return await ctx.send(embed=err_embed("File must be .json"))
raw = await att.read()
try:
data = json.loads(raw)
except json.JSONDecodeError:
return await ctx.send(embed=err_embed("Invalid JSON."))
async with self.config.networks() as ns:
ns[name] = data
await ctx.send(embed=ok_embed(f"Network `{name}` restored from backup."))
# ── Version ────────────────────────────────────────────────────────────
@commands.hybrid_command(name="wh-version")
async def wh_version(self, ctx: commands.Context) -> None:
"""Show Wormhole cog version."""
nets = await self.config.networks()
total_ch = sum(len(d.get("channels", [])) for d in nets.values())
total_msg = sum(d.get("total_messages", 0) for d in nets.values())
em = discord.Embed(
title="🌀 Wormhole",
description=(
f"**Version:** {self.__version__}\n"
f"**Networks:** {len(nets)}\n"
f"**Channels:** {total_ch}\n"
f"**Messages relayed:** {total_msg:,}"
),
colour=COLOUR_INFO,
)
await ctx.send(embed=em)

106
wormhole/commands/dm.py Normal file
View file

@ -0,0 +1,106 @@
"""DM relay commands — subscribe, send, configure DM relay."""
from __future__ import annotations
import discord
from redbot.core import commands
from ..models.permissions import Role, requires_role
from ..utils import ok_embed, err_embed, info_embed, dm_embed, truncate
class DMCommands:
"""Mixin — DM relay management."""
@commands.group(name="wh-dm", aliases=["whdm"], invoke_without_command=True)
async def wh_dm(self, ctx: commands.Context) -> None:
"""DM relay — receive network messages in your DMs."""
await ctx.send_help(ctx.command)
@wh_dm.command(name="enable")
@requires_role(Role.ADMIN)
async def wh_dm_enable(self, ctx: commands.Context, name: str, enabled: bool = True) -> None:
"""Enable or disable DM relay for a network."""
async with self.config.networks() as ns:
ns[name]["dm_enabled"] = enabled
await ctx.send(embed=ok_embed(f"DM relay {'enabled' if enabled else 'disabled'} for `{name}`."))
@wh_dm.command(name="subscribe", aliases=["sub"])
async def wh_dm_sub(self, ctx: commands.Context, name: str) -> None:
"""Subscribe to receive network messages via DM."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
if not nd.get("dm_enabled"):
return await ctx.send(embed=err_embed(f"DM relay isn't enabled for `{name}`."))
async with self.config.networks() as ns:
subs = ns[name].setdefault("dm_subscribers", [])
if ctx.author.id in subs:
return await ctx.send(embed=err_embed("You're already subscribed."))
subs.append(ctx.author.id)
await ctx.send(embed=ok_embed(f"Subscribed to DM relay for `{name}`."))
@wh_dm.command(name="unsubscribe", aliases=["unsub"])
async def wh_dm_unsub(self, ctx: commands.Context, name: str) -> None:
"""Unsubscribe from DM relay."""
async with self.config.networks() as ns:
subs = ns[name].get("dm_subscribers", [])
if ctx.author.id in subs:
subs.remove(ctx.author.id)
await ctx.send(embed=ok_embed(f"Unsubscribed from DM relay for `{name}`."))
@wh_dm.command(name="mode")
@requires_role(Role.ADMIN)
async def wh_dm_mode(self, ctx: commands.Context, name: str, mode: str) -> None:
"""Set DM relay mode: ``embed``, ``compact``, or ``plain``."""
if mode not in ("embed", "compact", "plain"):
return await ctx.send(embed=err_embed("Mode must be `embed`, `compact`, or `plain`."))
async with self.config.networks() as ns:
ns[name]["dm_relay_mode"] = mode
await ctx.send(embed=ok_embed(f"DM relay mode set to **{mode}** for `{name}`."))
@wh_dm.command(name="send")
async def wh_dm_send(self, ctx: commands.Context, name: str, *, message: str) -> None:
"""Send a message to the network from your DMs."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
if not nd.get("dm_enabled"):
return await ctx.send(embed=err_embed("DM relay isn't enabled for this network."))
if ctx.author.id in nd.get("banned_users", []):
return await ctx.send(embed=err_embed("You're banned from this network."))
if ctx.author.id in nd.get("muted_users", []):
return await ctx.send(embed=err_embed("You're muted in this network."))
from ..utils import build_dm_relay_embed
em = build_dm_relay_embed(ctx.author, message, name)
for ch_id in nd.get("channels", []):
ch = self.bot.get_channel(ch_id)
if ch:
try:
await ch.send(embed=em)
except Exception:
pass
await ctx.send(embed=ok_embed("Message sent to the network."))
@wh_dm.command(name="ignore")
async def wh_dm_ignore(self, ctx: commands.Context, name: str, user: discord.User) -> None:
"""Ignore a user in DM relay (you won't see their messages)."""
async with self.config.networks() as ns:
ignores = ns[name].setdefault("user_ignores", {})
uid_str = str(ctx.author.id)
user_ignores = ignores.setdefault(uid_str, [])
if user.id not in user_ignores:
user_ignores.append(user.id)
await ctx.send(embed=ok_embed(f"{user.display_name} ignored in DM relay for `{name}`."))
@wh_dm.command(name="unignore")
async def wh_dm_unignore(self, ctx: commands.Context, name: str, user: discord.User) -> None:
"""Stop ignoring a user in DM relay."""
async with self.config.networks() as ns:
ignores = ns[name].get("user_ignores", {})
uid_str = str(ctx.author.id)
user_ignores = ignores.get(uid_str, [])
if user.id in user_ignores:
user_ignores.remove(user.id)
await ctx.send(embed=ok_embed(f"{user.display_name} unignored in DM relay for `{name}`."))

View file

@ -0,0 +1,188 @@
"""Filter commands — word/regex filters, automod configuration, attachment filters."""
from __future__ import annotations
import discord
from redbot.core import commands
from ..models.permissions import Role, requires_role
from ..utils import ok_embed, err_embed, info_embed, COLOUR_INFO
class FilterCommands:
"""Mixin — content filters and automod configuration."""
@commands.group(name="wh-filter", aliases=["whfilter"], invoke_without_command=True)
async def wh_filter(self, ctx: commands.Context) -> None:
"""Manage content filters and automod."""
await ctx.send_help(ctx.command)
# ── Word filters ───────────────────────────────────────────────────────
@wh_filter.command(name="addword")
@requires_role(Role.MODERATOR)
async def wh_filter_addword(self, ctx: commands.Context, name: str, *, word: str) -> None:
"""Add a word to the filter list."""
async with self.config.networks() as ns:
wf = ns[name].setdefault("word_filters", [])
if word.lower() not in [w.lower() for w in wf]:
wf.append(word)
await ctx.send(embed=ok_embed(f"Word `{word}` added to filters for `{name}`."))
await self._audit(name, "filter_add", str(ctx.author), word)
@wh_filter.command(name="rmword")
@requires_role(Role.MODERATOR)
async def wh_filter_rmword(self, ctx: commands.Context, name: str, *, word: str) -> None:
"""Remove a word from the filter list."""
async with self.config.networks() as ns:
wf = ns[name].get("word_filters", [])
ns[name]["word_filters"] = [w for w in wf if w.lower() != word.lower()]
await ctx.send(embed=ok_embed(f"Word `{word}` removed from filters for `{name}`."))
@wh_filter.command(name="addregex")
@requires_role(Role.ADMIN)
async def wh_filter_addregex(self, ctx: commands.Context, name: str, *, pattern: str) -> None:
"""Add a regex pattern to the filter list."""
import re
try:
re.compile(pattern)
except re.error as e:
return await ctx.send(embed=err_embed(f"Invalid regex: {e}"))
async with self.config.networks() as ns:
rf = ns[name].setdefault("regex_filters", [])
if pattern not in rf:
rf.append(pattern)
await ctx.send(embed=ok_embed(f"Regex `{pattern}` added to filters for `{name}`."))
await self._audit(name, "regex_add", str(ctx.author), pattern)
@wh_filter.command(name="rmregex")
@requires_role(Role.ADMIN)
async def wh_filter_rmregex(self, ctx: commands.Context, name: str, *, pattern: str) -> None:
"""Remove a regex pattern from the filter list."""
async with self.config.networks() as ns:
rf = ns[name].get("regex_filters", [])
if pattern in rf:
rf.remove(pattern)
await ctx.send(embed=ok_embed(f"Regex removed from filters for `{name}`."))
@wh_filter.command(name="list")
@requires_role(Role.HELPER)
async def wh_filter_list(self, ctx: commands.Context, name: str) -> None:
"""List all active filters."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
words = nd.get("word_filters", [])
regexes = nd.get("regex_filters", [])
lines = []
if words:
lines.append("**Word Filters:**")
for w in words:
lines.append(f" • `{w}`")
if regexes:
lines.append("**Regex Filters:**")
for r in regexes:
lines.append(f" • `{r}`")
if not lines:
lines.append("No filters configured.")
await ctx.send(embed=info_embed("\n".join(lines), title=f"🔍 Filters — {name}"))
# ── Automod ────────────────────────────────────────────────────────────
@wh_filter.command(name="automod")
@requires_role(Role.ADMIN)
async def wh_filter_automod(self, ctx: commands.Context, name: str, feature: str, enabled: bool) -> None:
"""Toggle automod features.
Features: ``spam``, ``invite``, ``link``, ``caps``, ``mention_spam``,
``zalgo``, ``spoiler``, ``emote_spam``, ``newline_spam``, ``raid``
"""
key_map = {
"spam": "anti_spam", "invite": "anti_invite",
"link": "anti_link", "caps": "anti_caps",
"mention_spam": "anti_mention_spam", "zalgo": "anti_zalgo",
"spoiler": "anti_spoiler", "emote_spam": "anti_emote_spam",
"newline_spam": "anti_newline_spam", "raid": "anti_raid",
}
if feature not in key_map:
return await ctx.send(embed=err_embed(f"Unknown feature. Options: {', '.join(key_map)}"))
async with self.config.networks() as ns:
am = ns[name].setdefault("automod", {})
am[key_map[feature]] = enabled
am["enabled"] = any(v for k, v in am.items() if k.startswith("anti_"))
await ctx.send(embed=ok_embed(f"Automod `{feature}` {'enabled' if enabled else 'disabled'} for `{name}`."))
await self._audit(name, f"automod_{feature}", str(ctx.author), details=str(enabled))
# Update detectors
if feature == "spam":
if enabled:
nd = await self._net(name)
am = nd.get("automod", {})
from ..utils import DuplicateDetector
self.dup_detectors[name] = DuplicateDetector(am.get("spam_window", 30.0), am.get("spam_threshold", 3))
else:
self.dup_detectors.pop(name, None)
elif feature == "raid":
if enabled:
nd = await self._net(name)
am = nd.get("automod", {})
from ..utils import RaidDetector
self.raid_detectors[name] = RaidDetector(am.get("raid_window", 60.0), am.get("raid_threshold", 10))
else:
self.raid_detectors.pop(name, None)
@wh_filter.command(name="automod-set")
@requires_role(Role.ADMIN)
async def wh_filter_automod_set(self, ctx: commands.Context, name: str, key: str, value: str) -> None:
"""Set an automod parameter.
Keys: ``max_mentions``, ``caps_threshold``, ``spam_window``, ``spam_threshold``,
``max_emotes``, ``max_newlines``, ``raid_window``, ``raid_threshold``
"""
valid_keys = {
"max_mentions", "caps_threshold", "spam_window", "spam_threshold",
"max_emotes", "max_newlines", "raid_window", "raid_threshold",
}
if key not in valid_keys:
return await ctx.send(embed=err_embed(f"Unknown key. Options: {', '.join(valid_keys)}"))
try:
parsed = float(value) if "." in value else int(value)
except ValueError:
return await ctx.send(embed=err_embed("Value must be a number."))
async with self.config.networks() as ns:
ns[name].setdefault("automod", {})[key] = parsed
await ctx.send(embed=ok_embed(f"Automod `{key}` set to `{parsed}` for `{name}`."))
# ── Attachment filters ─────────────────────────────────────────────────
@wh_filter.command(name="blockext")
@requires_role(Role.ADMIN)
async def wh_filter_blockext(self, ctx: commands.Context, name: str, ext: str) -> None:
"""Block a file extension (e.g. ``.exe``)."""
if not ext.startswith("."):
ext = "." + ext
async with self.config.networks() as ns:
exts = ns[name].setdefault("blocked_extensions", [])
if ext not in exts:
exts.append(ext)
await ctx.send(embed=ok_embed(f"Extension `{ext}` blocked for `{name}`."))
@wh_filter.command(name="unblockext")
@requires_role(Role.ADMIN)
async def wh_filter_unblockext(self, ctx: commands.Context, name: str, ext: str) -> None:
"""Unblock a file extension."""
if not ext.startswith("."):
ext = "." + ext
async with self.config.networks() as ns:
exts = ns[name].get("blocked_extensions", [])
if ext in exts:
exts.remove(ext)
await ctx.send(embed=ok_embed(f"Extension `{ext}` unblocked for `{name}`."))
@wh_filter.command(name="maxfilesize")
@requires_role(Role.ADMIN)
async def wh_filter_maxfilesize(self, ctx: commands.Context, name: str, mb: float = 0) -> None:
"""Set max file size in MB (0 to disable)."""
async with self.config.networks() as ns:
ns[name]["max_filesize"] = int(mb * 1024 * 1024) if mb > 0 else None
await ctx.send(embed=ok_embed(f"Max file size {'set to ' + str(mb) + ' MB' if mb > 0 else 'disabled'} for `{name}`."))

View file

@ -0,0 +1,112 @@
"""Mention policy commands — granular per-network, per-server, per-user controls."""
from __future__ import annotations
import discord
from redbot.core import commands
from ..models.permissions import Role, requires_role
from ..utils import ok_embed, err_embed, info_embed, COLOUR_INFO
class MentionCommands:
"""Mixin — mention policy management."""
@commands.group(name="wh-mention", aliases=["whmention"], invoke_without_command=True)
async def wh_mention(self, ctx: commands.Context) -> None:
"""Configure mention behaviour."""
await ctx.send_help(ctx.command)
@wh_mention.command(name="set")
@requires_role(Role.ADMIN)
async def wh_mention_set(self, ctx: commands.Context, name: str, kind: str, allowed: bool) -> None:
"""Set a mention policy.
Kinds: ``user``, ``role``, ``everyone``, ``here``
"""
key_map = {
"user": "allow_user_mentions",
"role": "allow_role_mentions",
"everyone": "allow_everyone",
"here": "allow_here",
}
if kind not in key_map:
return await ctx.send(embed=err_embed(f"Unknown kind. Options: {', '.join(key_map)}"))
async with self.config.networks() as ns:
policy = ns[name].setdefault("mention_policy", {})
policy[key_map[kind]] = allowed
await ctx.send(embed=ok_embed(f"`{kind}` mentions {'allowed' if allowed else 'blocked'} in `{name}`."))
await self._audit(name, f"mention_{kind}", str(ctx.author), details=str(allowed))
@wh_mention.command(name="exempt")
@requires_role(Role.ADMIN)
async def wh_mention_exempt(self, ctx: commands.Context, name: str, user: discord.User) -> None:
"""Exempt a user from mention restrictions."""
async with self.config.networks() as ns:
exempt = ns[name].setdefault("mention_exempt_users", [])
if user.id not in exempt:
exempt.append(user.id)
await ctx.send(embed=ok_embed(f"{user.mention} exempted from mention restrictions in `{name}`."))
@wh_mention.command(name="unexempt")
@requires_role(Role.ADMIN)
async def wh_mention_unexempt(self, ctx: commands.Context, name: str, user: discord.User) -> None:
"""Remove a user's mention exemption."""
async with self.config.networks() as ns:
exempt = ns[name].get("mention_exempt_users", [])
if user.id in exempt:
exempt.remove(user.id)
await ctx.send(embed=ok_embed(f"{user.mention} exemption removed in `{name}`."))
@wh_mention.command(name="optout")
async def wh_mention_optout(self, ctx: commands.Context, name: str) -> None:
"""Opt out of being pinged via relay."""
async with self.config.networks() as ns:
optout = ns[name].setdefault("mention_optout_users", [])
if ctx.author.id not in optout:
optout.append(ctx.author.id)
await ctx.send(embed=ok_embed("You've opted out of relay pings."))
@wh_mention.command(name="optin")
async def wh_mention_optin(self, ctx: commands.Context, name: str) -> None:
"""Opt back in to relay pings."""
async with self.config.networks() as ns:
optout = ns[name].get("mention_optout_users", [])
if ctx.author.id in optout:
optout.remove(ctx.author.id)
await ctx.send(embed=ok_embed("You've opted back in to relay pings."))
@wh_mention.command(name="serveroverride")
@requires_role(Role.ADMIN)
async def wh_mention_serveroverride(self, ctx: commands.Context, name: str, guild_id: int, kind: str, allowed: bool) -> None:
"""Set per-server mention override."""
key_map = {
"user": "allow_user_mentions",
"role": "allow_role_mentions",
"everyone": "allow_everyone",
"here": "allow_here",
}
if kind not in key_map:
return await ctx.send(embed=err_embed(f"Unknown kind. Options: {', '.join(key_map)}"))
async with self.config.networks() as ns:
so = ns[name].setdefault("server_mention_overrides", {})
gid_str = str(guild_id)
so.setdefault(gid_str, {})[key_map[kind]] = allowed
await ctx.send(embed=ok_embed(f"Server override set for `{guild_id}` in `{name}`."))
@wh_mention.command(name="show")
@requires_role(Role.HELPER)
async def wh_mention_show(self, ctx: commands.Context, name: str) -> None:
"""Show current mention policy."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
policy = nd.get("mention_policy", {})
em = discord.Embed(title=f"📢 Mention Policy — {name}", colour=COLOUR_INFO)
em.add_field(name="User mentions", value="✅" if policy.get("allow_user_mentions") else "❌", inline=True)
em.add_field(name="Role mentions", value="✅" if policy.get("allow_role_mentions") else "❌", inline=True)
em.add_field(name="@everyone", value="✅" if policy.get("allow_everyone") else "❌", inline=True)
em.add_field(name="@here", value="✅" if policy.get("allow_here") else "❌", inline=True)
em.add_field(name="Exempt users", value=str(len(nd.get("mention_exempt_users", []))), inline=True)
em.add_field(name="Opted out", value=str(len(nd.get("mention_optout_users", []))), inline=True)
await ctx.send(embed=em)

View file

@ -0,0 +1,308 @@
"""Moderation commands — ban, unban, mute, unmute, purge, mod edit/delete, warnings."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
import discord
from redbot.core import commands
from ..models.permissions import Role, has_role, requires_role, role_name, get_role
from ..utils import ok_embed, err_embed, info_embed, warn_embed, truncate, COLOUR_INFO
class ModerationCommands:
"""Mixin — moderation tools (per-network, role-gated)."""
@commands.group(name="wh-mod", aliases=["whmod"], invoke_without_command=True)
async def wh_mod(self, ctx: commands.Context) -> None:
"""Network moderation tools."""
await ctx.send_help(ctx.command)
# ── User bans ──────────────────────────────────────────────────────────
@wh_mod.command(name="ban")
@requires_role(Role.MODERATOR)
async def wh_mod_ban(self, ctx: commands.Context, name: str, user: discord.User, *, reason: str = "No reason") -> None:
"""Ban a user from the network."""
nd = await self._net(name)
if user.id == nd["owner_id"]:
return await ctx.send(embed=err_embed("You can't ban the network owner."))
target_role = get_role(nd, user.id)
actor_role = get_role(nd, ctx.author.id)
if target_role >= actor_role and not await self.bot.is_owner(ctx.author):
return await ctx.send(embed=err_embed(f"You can't ban a {role_name(target_role)} — your role: {role_name(actor_role)}"))
async with self.config.networks() as ns:
bans = ns[name].setdefault("banned_users", [])
if user.id not in bans:
bans.append(user.id)
await ctx.send(embed=ok_embed(f"{user.mention} banned from `{name}`.\nReason: {reason}"))
await self._audit(name, "ban", str(ctx.author), str(user), reason)
await self._log(nd, warn_embed(f"🔨 **{user}** banned by {ctx.author}\nReason: {reason}"))
@wh_mod.command(name="unban")
@requires_role(Role.MODERATOR)
async def wh_mod_unban(self, ctx: commands.Context, name: str, user: discord.User) -> None:
"""Unban a user from the network."""
async with self.config.networks() as ns:
bans = ns[name].get("banned_users", [])
if user.id in bans:
bans.remove(user.id)
await ctx.send(embed=ok_embed(f"{user.mention} unbanned from `{name}`."))
await self._audit(name, "unban", str(ctx.author), str(user))
# ── User mutes ─────────────────────────────────────────────────────────
@wh_mod.command(name="mute")
@requires_role(Role.MODERATOR)
async def wh_mod_mute(self, ctx: commands.Context, name: str, user: discord.User, *, reason: str = "No reason") -> None:
"""Mute a user in the network (messages won't relay)."""
nd = await self._net(name)
target_role = get_role(nd, user.id)
actor_role = get_role(nd, ctx.author.id)
if target_role >= actor_role and not await self.bot.is_owner(ctx.author):
return await ctx.send(embed=err_embed(f"You can't mute a {role_name(target_role)}."))
async with self.config.networks() as ns:
mutes = ns[name].setdefault("muted_users", [])
if user.id not in mutes:
mutes.append(user.id)
await ctx.send(embed=ok_embed(f"{user.mention} muted in `{name}`.\nReason: {reason}"))
await self._audit(name, "mute", str(ctx.author), str(user), reason)
await self._log(nd, warn_embed(f"🔇 **{user}** muted by {ctx.author}\nReason: {reason}"))
@wh_mod.command(name="unmute")
@requires_role(Role.MODERATOR)
async def wh_mod_unmute(self, ctx: commands.Context, name: str, user: discord.User) -> None:
"""Unmute a user in the network."""
async with self.config.networks() as ns:
mutes = ns[name].get("muted_users", [])
if user.id in mutes:
mutes.remove(user.id)
await ctx.send(embed=ok_embed(f"{user.mention} unmuted in `{name}`."))
await self._audit(name, "unmute", str(ctx.author), str(user))
# ── Server bans/mutes ──────────────────────────────────────────────────
@wh_mod.command(name="banserver")
@requires_role(Role.ADMIN)
async def wh_mod_banserver(self, ctx: commands.Context, name: str, guild_id: int) -> None:
"""Ban a server from the network."""
async with self.config.networks() as ns:
bans = ns[name].setdefault("banned_servers", [])
if guild_id not in bans:
bans.append(guild_id)
await ctx.send(embed=ok_embed(f"Server `{guild_id}` banned from `{name}`."))
await self._audit(name, "banserver", str(ctx.author), str(guild_id))
@wh_mod.command(name="unbanserver")
@requires_role(Role.ADMIN)
async def wh_mod_unbanserver(self, ctx: commands.Context, name: str, guild_id: int) -> None:
"""Unban a server from the network."""
async with self.config.networks() as ns:
bans = ns[name].get("banned_servers", [])
if guild_id in bans:
bans.remove(guild_id)
await ctx.send(embed=ok_embed(f"Server `{guild_id}` unbanned from `{name}`."))
await self._audit(name, "unbanserver", str(ctx.author), str(guild_id))
@wh_mod.command(name="muteserver")
@requires_role(Role.ADMIN)
async def wh_mod_muteserver(self, ctx: commands.Context, name: str, guild_id: int) -> None:
"""Mute a server in the network."""
async with self.config.networks() as ns:
mutes = ns[name].setdefault("muted_servers", [])
if guild_id not in mutes:
mutes.append(guild_id)
await ctx.send(embed=ok_embed(f"Server `{guild_id}` muted in `{name}`."))
await self._audit(name, "muteserver", str(ctx.author), str(guild_id))
@wh_mod.command(name="unmuteserver")
@requires_role(Role.ADMIN)
async def wh_mod_unmuteserver(self, ctx: commands.Context, name: str, guild_id: int) -> None:
"""Unmute a server in the network."""
async with self.config.networks() as ns:
mutes = ns[name].get("muted_servers", [])
if guild_id in mutes:
mutes.remove(guild_id)
await ctx.send(embed=ok_embed(f"Server `{guild_id}` unmuted in `{name}`."))
await self._audit(name, "unmuteserver", str(ctx.author), str(guild_id))
# ── Allowlist ──────────────────────────────────────────────────────────
@wh_mod.command(name="allowserver")
@requires_role(Role.ADMIN)
async def wh_mod_allowserver(self, ctx: commands.Context, name: str, guild_id: int) -> None:
"""Add a server to the allowlist."""
async with self.config.networks() as ns:
allow = ns[name].setdefault("allowlist_servers", [])
if guild_id not in allow:
allow.append(guild_id)
await ctx.send(embed=ok_embed(f"Server `{guild_id}` added to allowlist for `{name}`."))
@wh_mod.command(name="removeallow")
@requires_role(Role.ADMIN)
async def wh_mod_removeallow(self, ctx: commands.Context, name: str, guild_id: int) -> None:
"""Remove a server from the allowlist."""
async with self.config.networks() as ns:
allow = ns[name].get("allowlist_servers", [])
if guild_id in allow:
allow.remove(guild_id)
await ctx.send(embed=ok_embed(f"Server `{guild_id}` removed from allowlist for `{name}`."))
# ── Purge ──────────────────────────────────────────────────────────────
@wh_mod.command(name="purge")
@requires_role(Role.MODERATOR)
async def wh_mod_purge(self, ctx: commands.Context, name: str, count: int = 10) -> None:
"""Purge recent relayed messages across all channels (max 50)."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
count = min(count, 50)
deleted = 0
for ch_id in nd.get("channels", []):
ch = self.bot.get_channel(ch_id)
if not ch:
continue
try:
async for msg in ch.history(limit=count):
if msg.author.id == self.bot.user.id or (msg.webhook_id is not None):
try:
await msg.delete()
deleted += 1
except Exception:
pass
except Exception:
pass
await ctx.send(embed=ok_embed(f"Purged {deleted} relayed messages across `{name}`."))
await self._audit(name, "purge", str(ctx.author), details=f"count={count}")
# ── Cross-network mod edit/delete ──────────────────────────────────────
@wh_mod.command(name="edit")
@requires_role(Role.MODERATOR)
async def wh_mod_edit(self, ctx: commands.Context, name: str, message_id: int, *, new_content: str) -> None:
"""Edit a relayed message across all channels."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
orig_id = self.msg_map.get_original(name, message_id) or message_id
relayed = self.msg_map.get_relayed(name, orig_id)
edited = 0
for ch_id, msg_id in relayed.items():
ch = self.bot.get_channel(ch_id)
if not ch:
continue
try:
wh = await self._wh(ch)
await wh.edit_message(msg_id, content=new_content)
edited += 1
except Exception:
try:
msg = await ch.fetch_message(msg_id)
if msg.author.id == self.bot.user.id:
await msg.edit(content=new_content)
edited += 1
except Exception:
pass
await ctx.send(embed=ok_embed(f"Edited {edited} relayed copies."))
await self._audit(name, "mod_edit", str(ctx.author), str(orig_id), truncate(new_content, 100))
@wh_mod.command(name="nuke")
@requires_role(Role.MODERATOR)
async def wh_mod_nuke(self, ctx: commands.Context, name: str, message_id: int) -> None:
"""Delete a message and all its relayed copies across the network."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
orig_id = self.msg_map.get_original(name, message_id) or message_id
relayed = self.msg_map.get_relayed(name, orig_id)
deleted = 0
for ch_id, msg_id in relayed.items():
ch = self.bot.get_channel(ch_id)
if ch:
try:
msg = await ch.fetch_message(msg_id)
await msg.delete()
deleted += 1
except Exception:
pass
# Also try to delete in source channels
for ch_id in nd.get("channels", []):
ch = self.bot.get_channel(ch_id)
if ch:
try:
msg = await ch.fetch_message(orig_id)
await msg.delete()
deleted += 1
except Exception:
pass
await ctx.send(embed=ok_embed(f"Nuked {deleted} message(s) across `{name}`."))
await self._audit(name, "nuke", str(ctx.author), str(orig_id))
# ── Audit log ──────────────────────────────────────────────────────────
@wh_mod.command(name="audit")
@requires_role(Role.HELPER)
async def wh_mod_audit(self, ctx: commands.Context, name: str, count: int = 20) -> None:
"""View the network audit log."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
entries = nd.get("audit_log", [])[-count:]
if not entries:
return await ctx.send(embed=info_embed("Audit log is empty."))
lines = []
for e in reversed(entries):
ts = e.get("timestamp", "?")[:16]
act = e.get("action", "?")
usr = e.get("user", "?")
tgt = e.get("target", "")
det = e.get("details", "")
line = f"`{ts}` **{act}** by {usr}"
if tgt:
line += f" → {tgt}"
if det:
line += f" ({det})"
lines.append(line)
em = discord.Embed(
title=f"📋 Audit Log — {name}",
description="\n".join(lines),
colour=COLOUR_INFO,
)
await ctx.send(embed=em)
# ── Ban/mute lists ─────────────────────────────────────────────────────
@wh_mod.command(name="bans")
@requires_role(Role.HELPER)
async def wh_mod_bans(self, ctx: commands.Context, name: str) -> None:
"""List banned users."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
bans = nd.get("banned_users", [])
if not bans:
return await ctx.send(embed=info_embed(f"No banned users in `{name}`."))
lines = []
for uid in bans:
user = self.bot.get_user(uid)
lines.append(f"• {user or uid}")
await ctx.send(embed=info_embed("\n".join(lines), title=f"🔨 Banned Users — {name}"))
@wh_mod.command(name="mutes")
@requires_role(Role.HELPER)
async def wh_mod_mutes(self, ctx: commands.Context, name: str) -> None:
"""List muted users."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
mutes = nd.get("muted_users", [])
if not mutes:
return await ctx.send(embed=info_embed(f"No muted users in `{name}`."))
lines = []
for uid in mutes:
user = self.bot.get_user(uid)
lines.append(f"• {user or uid}")
await ctx.send(embed=info_embed("\n".join(lines), title=f"🔇 Muted Users — {name}"))

View file

@ -0,0 +1,167 @@
"""Network lifecycle commands — create, delete, open, close, list, discover, info."""
from __future__ import annotations
from copy import deepcopy
from datetime import datetime, timezone
from typing import Optional
import discord
from redbot.core import commands
from ..models.config import DEFAULT_NETWORK
from ..models.permissions import Role, has_role, requires_role, role_name, get_role, list_staff
from ..utils import ok_embed, err_embed, info_embed, warn_embed, COLOUR_INFO, COLOUR_OK, truncate
class NetworkCommands:
"""Mixin — network lifecycle commands."""
@commands.hybrid_command(name="wh-create")
async def wh_create(self, ctx: commands.Context, name: str, *, description: str = "") -> None:
"""Create a new wormhole network."""
nets = await self.config.networks()
if name in nets:
return await ctx.send(embed=err_embed(f"Network `{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've hit the limit of {mx} networks."))
d = deepcopy(DEFAULT_NETWORK)
d["owner_id"] = ctx.author.id
d["description"] = description
d["created_at"] = datetime.now(timezone.utc).isoformat()
async with self.config.networks() as ns:
ns[name] = d
from ..utils import CooldownBucket
self.cooldowns[name] = CooldownBucket(d["rate_limit_rate"], d["rate_limit_per"])
await ctx.send(embed=ok_embed(
f"Network `{name}` created!\n"
f"Open channels with `wh open {name}` in target channels."
))
await self._audit(name, "create", str(ctx.author))
@commands.hybrid_command(name="wh-delete")
async def wh_delete(self, ctx: commands.Context, name: str) -> None:
"""Delete a network (owner or bot owner only)."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
if not await self.bot.is_owner(ctx.author):
if not has_role(nd, ctx.author.id, Role.OWNER):
return await ctx.send(embed=err_embed("Only the network owner or bot owner can delete a network."))
async with self.config.networks() as ns:
ns.pop(name, None)
self.cooldowns.pop(name, None)
await ctx.send(embed=ok_embed(f"Network `{name}` deleted."))
@commands.hybrid_command(name="wh-open")
@requires_role(Role.ADMIN)
async def wh_open(self, ctx: commands.Context, name: str) -> None:
"""Add the current channel to a network."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
if ctx.channel.id in nd.get("channels", []):
return await ctx.send(embed=err_embed("This channel is already in the network."))
# Check not in another network
existing = await self._net_for_ch(ctx.channel.id)
if existing:
return await ctx.send(embed=err_embed(f"This channel is already in network `{existing}`."))
async with self.config.networks() as ns:
ns[name].setdefault("channels", []).append(ctx.channel.id)
await ctx.send(embed=ok_embed(f"Channel linked to `{name}`. Messages will now relay."))
await self._audit(name, "open", str(ctx.author), str(ctx.channel.id))
@commands.hybrid_command(name="wh-close")
async def wh_close(self, ctx: commands.Context, name: str = None) -> None:
"""Remove the current channel from a network."""
if not name:
name = await self._net_for_ch(ctx.channel.id)
if not name:
return await ctx.send(embed=err_embed("This channel isn't in any network."))
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
if not has_role(nd, ctx.author.id, Role.ADMIN) and not await self.bot.is_owner(ctx.author):
return await ctx.send(embed=err_embed("You need Admin role or higher."))
if ctx.channel.id not in nd.get("channels", []):
return await ctx.send(embed=err_embed("This channel isn't in that network."))
async with self.config.networks() as ns:
ns[name]["channels"].remove(ctx.channel.id)
await ctx.send(embed=ok_embed(f"Channel removed from `{name}`."))
await self._audit(name, "close", str(ctx.author), str(ctx.channel.id))
@commands.hybrid_command(name="wh-list")
async def wh_list(self, ctx: commands.Context) -> None:
"""List all networks you have access to."""
nets = await self.config.networks()
if not nets:
return await ctx.send(embed=info_embed("No networks exist yet."))
lines = []
for n, d in sorted(nets.items()):
status = "❄️" if d.get("frozen") else "🟢"
ch_count = len(d.get("channels", []))
role = get_role(d, ctx.author.id)
role_tag = f" [{role_name(role)}]" if role > Role.MEMBER else ""
lines.append(f"{status} **{n}** — {ch_count} channels, {d.get('total_messages', 0):,} msgs{role_tag}")
em = discord.Embed(title="🌀 Wormhole Networks", description="\n".join(lines), colour=COLOUR_INFO)
await ctx.send(embed=em)
@commands.hybrid_command(name="wh-discover")
async def wh_discover(self, ctx: commands.Context) -> None:
"""Browse public networks."""
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 public:
desc = truncate(d.get("description", ""), 60) or "No description"
tags = ", ".join(d.get("tags", [])) or "—"
lines.append(f"**{n}** — {desc}\n Tags: {tags} · {len(d.get('channels', []))} ch")
em = discord.Embed(title="🔍 Public Networks", description="\n".join(lines), colour=COLOUR_INFO)
await ctx.send(embed=em)
@commands.hybrid_command(name="wh-info")
async def wh_info(self, ctx: commands.Context, name: str) -> None:
"""Show detailed info about a network."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
colour = discord.Colour(nd["colour"]) if nd.get("colour") else COLOUR_INFO
status = "❄️ Frozen" if nd.get("frozen") else "🟢 Active"
owner = self.bot.get_user(nd["owner_id"])
em = discord.Embed(title=f"🌀 {name}", description=nd.get("description") or "No description", colour=colour)
em.add_field(name="Status", value=status, inline=True)
em.add_field(name="Owner", value=str(owner or nd["owner_id"]), inline=True)
# Staff count by role
staff = list_staff(nd)
staff_summary = []
for role in (Role.ADMIN, Role.MODERATOR, Role.HELPER):
count = sum(1 for r in staff.values() if r == role)
if count:
staff_summary.append(f"{role_name(role)}: {count}")
em.add_field(name="Staff", value="\n".join(staff_summary) if staff_summary else "None", inline=True)
em.add_field(name="Channels", value=str(len(nd.get("channels", []))), inline=True)
em.add_field(name="Messages", value=f"{nd.get('total_messages', 0):,}", inline=True)
em.add_field(name="Relay Mode", value=nd.get("relay_mode", "webhook"), inline=True)
features = []
for key, label in [
("sync_edits", "Edit sync"), ("sync_deletes", "Delete sync"),
("sync_reactions", "Reaction sync"), ("sync_replies", "Reply sync"),
("dm_enabled", "DM relay"), ("anonymous", "Anonymous"),
("rules_required", "ToS gate"), ("media_only", "Media-only"),
]:
if nd.get(key):
features.append(f"✅ {label}")
if features:
em.add_field(name="Features", value=" · ".join(features), inline=False)
your_role = get_role(nd, ctx.author.id)
em.set_footer(text=f"Your role: {role_name(your_role)} · Created {(nd.get('created_at') or '?')[:10]}")
await ctx.send(embed=em)

View file

@ -0,0 +1,138 @@
"""Report system commands — create, view, resolve, dismiss reports."""
from __future__ import annotations
from datetime import datetime, timezone
import discord
from redbot.core import commands
from ..models.permissions import Role, requires_role, has_role
from ..utils import ok_embed, err_embed, info_embed, warn_embed, truncate, COLOUR_INFO
class ReportCommands:
"""Mixin — user report system (per-network)."""
@commands.group(name="wh-report", aliases=["whreport"], invoke_without_command=True)
async def wh_report(self, ctx: commands.Context) -> None:
"""Network report system."""
await ctx.send_help(ctx.command)
@wh_report.command(name="msg")
async def wh_report_msg(self, ctx: commands.Context, message_id: int, *, reason: str = "No reason provided") -> None:
"""Report a message by ID."""
net_name = await self._net_for_ch(ctx.channel.id)
if not net_name:
return await ctx.send(embed=err_embed("This channel isn't in a network."))
nd = await self._net(net_name)
report_id = nd.get("report_counter", 0) + 1
report = {
"id": report_id,
"reporter_id": ctx.author.id,
"message_id": message_id,
"channel_id": ctx.channel.id,
"guild_id": ctx.guild.id if ctx.guild else None,
"reason": reason,
"timestamp": datetime.now(timezone.utc).isoformat(),
"resolved": False,
"resolved_by": None,
"resolution": None,
}
async with self.config.networks() as ns:
ns[net_name].setdefault("reports", []).append(report)
ns[net_name]["report_counter"] = report_id
await ctx.send(embed=ok_embed(f"Report #{report_id} submitted. Staff will review it."), delete_after=10)
await self._log(nd, warn_embed(
f"🚩 **Report #{report_id}**\n"
f"Reporter: {ctx.author} (`{ctx.author.id}`)\n"
f"Message ID: `{message_id}`\n"
f"Reason: {reason}",
title="New Report",
))
@wh_report.command(name="list")
@requires_role(Role.HELPER)
async def wh_report_list(self, ctx: commands.Context, name: str, show_resolved: bool = False) -> None:
"""List reports for a network."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
reports = nd.get("reports", [])
if not show_resolved:
reports = [r for r in reports if not r.get("resolved")]
if not reports:
return await ctx.send(embed=info_embed("No open reports."))
lines = []
for r in reports[-20:]:
status = "✅" if r.get("resolved") else "🔴"
reporter = self.bot.get_user(r.get("reporter_id"))
lines.append(
f"{status} **#{r['id']}** — by {reporter or r.get('reporter_id')}\n"
f" Reason: {truncate(r.get('reason', ''), 80)}"
)
em = discord.Embed(title=f"🚩 Reports — {name}", description="\n".join(lines), colour=COLOUR_INFO)
await ctx.send(embed=em)
@wh_report.command(name="view")
@requires_role(Role.HELPER)
async def wh_report_view(self, ctx: commands.Context, name: str, report_id: int) -> None:
"""View details of a specific report."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
report = None
for r in nd.get("reports", []):
if r.get("id") == report_id:
report = r
break
if not report:
return await ctx.send(embed=err_embed(f"Report #{report_id} not found."))
reporter = self.bot.get_user(report.get("reporter_id"))
em = discord.Embed(title=f"🚩 Report #{report_id}", colour=COLOUR_INFO)
em.add_field(name="Reporter", value=str(reporter or report.get("reporter_id")), inline=True)
em.add_field(name="Status", value="Resolved" if report.get("resolved") else "Open", inline=True)
em.add_field(name="Reason", value=report.get("reason", "N/A"), inline=False)
em.add_field(name="Timestamp", value=report.get("timestamp", "?")[:16], inline=True)
if report.get("message_id"):
em.add_field(name="Message ID", value=str(report["message_id"]), inline=True)
if report.get("resolved_by"):
resolver = self.bot.get_user(report["resolved_by"])
em.add_field(name="Resolved By", value=str(resolver or report["resolved_by"]), inline=True)
if report.get("resolution"):
em.add_field(name="Resolution", value=report["resolution"], inline=False)
await ctx.send(embed=em)
@wh_report.command(name="resolve")
@requires_role(Role.MODERATOR)
async def wh_report_resolve(self, ctx: commands.Context, name: str, report_id: int, *, resolution: str = "Resolved") -> None:
"""Resolve a report."""
async with self.config.networks() as ns:
for r in ns[name].get("reports", []):
if r.get("id") == report_id:
r["resolved"] = True
r["resolved_by"] = ctx.author.id
r["resolution"] = resolution
r["resolved_at"] = datetime.now(timezone.utc).isoformat()
break
else:
return await ctx.send(embed=err_embed(f"Report #{report_id} not found."))
await ctx.send(embed=ok_embed(f"Report #{report_id} resolved."))
await self._audit(name, "report_resolve", str(ctx.author), str(report_id), resolution)
@wh_report.command(name="dismiss")
@requires_role(Role.MODERATOR)
async def wh_report_dismiss(self, ctx: commands.Context, name: str, report_id: int, *, reason: str = "Dismissed") -> None:
"""Dismiss a report."""
async with self.config.networks() as ns:
for r in ns[name].get("reports", []):
if r.get("id") == report_id:
r["resolved"] = True
r["resolved_by"] = ctx.author.id
r["resolution"] = f"Dismissed: {reason}"
r["resolved_at"] = datetime.now(timezone.utc).isoformat()
break
else:
return await ctx.send(embed=err_embed(f"Report #{report_id} not found."))
await ctx.send(embed=ok_embed(f"Report #{report_id} dismissed."))
await self._audit(name, "report_dismiss", str(ctx.author), str(report_id), reason)

View file

@ -0,0 +1,356 @@
"""Settings commands — all `wh set` sub-commands for network configuration."""
from __future__ import annotations
from typing import Optional
import discord
from redbot.core import commands
from ..models.permissions import Role, requires_role
from ..utils import ok_embed, err_embed, info_embed, COLOUR_INFO, COLOUR_OK
class SettingsCommands:
"""Mixin — network settings management."""
@commands.group(name="wh-set", aliases=["whset"], invoke_without_command=True)
async def wh_set(self, ctx: commands.Context) -> None:
"""Configure network settings."""
await ctx.send_help(ctx.command)
# ── Relay mode ─────────────────────────────────────────────────────────
@wh_set.command(name="relaymode")
@requires_role(Role.ADMIN)
async def wh_set_relaymode(self, ctx: commands.Context, name: str, mode: str) -> None:
"""Set relay mode: ``webhook``, ``embed``, or ``compact``."""
if mode not in ("webhook", "embed", "compact"):
return await ctx.send(embed=err_embed("Mode must be `webhook`, `embed`, or `compact`."))
async with self.config.networks() as ns:
ns[name]["relay_mode"] = mode
await ctx.send(embed=ok_embed(f"Relay mode for `{name}` set to **{mode}**."))
await self._audit(name, "set_relaymode", str(ctx.author), details=mode)
# ── Webhooks ───────────────────────────────────────────────────────────
@wh_set.command(name="webhooks")
@requires_role(Role.ADMIN)
async def wh_set_webhooks(self, ctx: commands.Context, name: str, enabled: bool) -> None:
"""Enable or disable webhook relay."""
async with self.config.networks() as ns:
ns[name]["use_webhooks"] = enabled
await ctx.send(embed=ok_embed(f"Webhooks {'enabled' if enabled else 'disabled'} for `{name}`."))
await self._audit(name, "set_webhooks", str(ctx.author), details=str(enabled))
# ── Sync toggles ──────────────────────────────────────────────────────
@wh_set.command(name="sync")
@requires_role(Role.ADMIN)
async def wh_set_sync(self, ctx: commands.Context, name: str, feature: str, enabled: bool) -> None:
"""Toggle sync features: ``edits``, ``deletes``, ``reactions``, ``replies``, ``stickers``, ``pins``, ``threads``, ``typing``."""
key_map = {
"edits": "sync_edits", "deletes": "sync_deletes",
"reactions": "sync_reactions", "replies": "sync_replies",
"stickers": "sync_stickers", "pins": "sync_pins",
"threads": "sync_threads", "typing": "sync_typing",
}
if feature not in key_map:
return await ctx.send(embed=err_embed(f"Unknown feature. Options: {', '.join(key_map)}"))
async with self.config.networks() as ns:
ns[name][key_map[feature]] = enabled
await ctx.send(embed=ok_embed(f"`{feature}` sync {'enabled' if enabled else 'disabled'} for `{name}`."))
await self._audit(name, f"set_{feature}", str(ctx.author), details=str(enabled))
# ── Identity ───────────────────────────────────────────────────────────
@wh_set.command(name="imagemode")
@requires_role(Role.ADMIN)
async def wh_set_imagemode(self, ctx: commands.Context, name: str, mode: str) -> None:
"""Set avatar mode: ``user``, ``server``, ``custom``."""
if mode not in ("user", "server", "custom"):
return await ctx.send(embed=err_embed("Mode must be `user`, `server`, or `custom`."))
async with self.config.networks() as ns:
ns[name]["image_mode"] = mode
await ctx.send(embed=ok_embed(f"Image mode for `{name}` set to **{mode}**."))
@wh_set.command(name="namemode")
@requires_role(Role.ADMIN)
async def wh_set_namemode(self, ctx: commands.Context, name: str, mode: str) -> None:
"""Set name mode: ``user``, ``server``, ``both``, ``custom``."""
if mode not in ("user", "server", "both", "custom"):
return await ctx.send(embed=err_embed("Mode must be `user`, `server`, `both`, or `custom`."))
async with self.config.networks() as ns:
ns[name]["name_mode"] = mode
await ctx.send(embed=ok_embed(f"Name mode for `{name}` set to **{mode}**."))
@wh_set.command(name="icon")
@requires_role(Role.ADMIN)
async def wh_set_icon(self, ctx: commands.Context, name: str, url: str = None) -> None:
"""Set a custom network icon URL."""
async with self.config.networks() as ns:
ns[name]["custom_icon"] = url
await ctx.send(embed=ok_embed(f"Custom icon {'set' if url else 'cleared'} for `{name}`."))
@wh_set.command(name="customname")
@requires_role(Role.ADMIN)
async def wh_set_customname(self, ctx: commands.Context, name: str, *, value: str = None) -> None:
"""Set a custom display name for relayed messages."""
async with self.config.networks() as ns:
ns[name]["custom_name"] = value
await ctx.send(embed=ok_embed(f"Custom name {'set' if value else 'cleared'} for `{name}`."))
@wh_set.command(name="colour", aliases=["color"])
@requires_role(Role.ADMIN)
async def wh_set_colour(self, ctx: commands.Context, name: str, hex_code: str = None) -> None:
"""Set the network embed colour (hex, e.g. ``#ff5733``)."""
if hex_code:
try:
colour = int(hex_code.lstrip("#"), 16)
except ValueError:
return await ctx.send(embed=err_embed("Invalid hex colour."))
else:
colour = None
async with self.config.networks() as ns:
ns[name]["colour"] = colour
await ctx.send(embed=ok_embed(f"Colour {'set' if colour else 'cleared'} for `{name}`."))
# ── Description / MOTD / rules ─────────────────────────────────────────
@wh_set.command(name="description", aliases=["desc"])
@requires_role(Role.ADMIN)
async def wh_set_desc(self, ctx: commands.Context, name: str, *, text: str) -> None:
"""Set the network description."""
async with self.config.networks() as ns:
ns[name]["description"] = text
await ctx.send(embed=ok_embed(f"Description updated for `{name}`."))
@wh_set.command(name="motd")
@requires_role(Role.ADMIN)
async def wh_set_motd(self, ctx: commands.Context, name: str, *, text: str = "") -> None:
"""Set or clear the Message of the Day."""
async with self.config.networks() as ns:
ns[name]["motd"] = text
await ctx.send(embed=ok_embed(f"MOTD {'set' if text else 'cleared'} for `{name}`."))
@wh_set.command(name="welcome")
@requires_role(Role.ADMIN)
async def wh_set_welcome(self, ctx: commands.Context, name: str, *, text: str = "") -> None:
"""Set or clear the welcome message for new servers."""
async with self.config.networks() as ns:
ns[name]["welcome_message"] = text
await ctx.send(embed=ok_embed(f"Welcome message {'set' if text else 'cleared'} for `{name}`."))
# ── Network behaviour ──────────────────────────────────────────────────
@wh_set.command(name="freeze")
@requires_role(Role.ADMIN)
async def wh_set_freeze(self, ctx: commands.Context, name: str) -> None:
"""Freeze the network — relay paused."""
async with self.config.networks() as ns:
ns[name]["frozen"] = True
await ctx.send(embed=ok_embed(f"`{name}` is now ❄️ frozen. Messages won't relay."))
await self._audit(name, "freeze", str(ctx.author))
@wh_set.command(name="unfreeze")
@requires_role(Role.ADMIN)
async def wh_set_unfreeze(self, ctx: commands.Context, name: str) -> None:
"""Unfreeze the network — resume relay."""
async with self.config.networks() as ns:
ns[name]["frozen"] = False
await ctx.send(embed=ok_embed(f"`{name}` is now 🟢 active."))
await self._audit(name, "unfreeze", str(ctx.author))
@wh_set.command(name="anonymous", aliases=["anon"])
@requires_role(Role.ADMIN)
async def wh_set_anonymous(self, ctx: commands.Context, name: str, enabled: bool) -> None:
"""Toggle anonymous mode."""
import secrets
async with self.config.networks() as ns:
ns[name]["anonymous"] = enabled
if enabled and not ns[name].get("anon_salt"):
ns[name]["anon_salt"] = secrets.token_hex(8)
await ctx.send(embed=ok_embed(f"Anonymous mode {'enabled' if enabled else 'disabled'} for `{name}`."))
@wh_set.command(name="nsfw")
@requires_role(Role.ADMIN)
async def wh_set_nsfw(self, ctx: commands.Context, name: str, gate: bool) -> None:
"""Toggle NSFW gate (only relay to NSFW channels if source is NSFW)."""
async with self.config.networks() as ns:
ns[name]["nsfw_gate"] = gate
await ctx.send(embed=ok_embed(f"NSFW gate {'enabled' if gate else 'disabled'} for `{name}`."))
@wh_set.command(name="mediaonly")
@requires_role(Role.ADMIN)
async def wh_set_mediaonly(self, ctx: commands.Context, name: str, enabled: bool) -> None:
"""Toggle media-only mode (only relay messages with attachments)."""
async with self.config.networks() as ns:
ns[name]["media_only"] = enabled
await ctx.send(embed=ok_embed(f"Media-only mode {'enabled' if enabled else 'disabled'} for `{name}`."))
@wh_set.command(name="silent")
@requires_role(Role.ADMIN)
async def wh_set_silent(self, ctx: commands.Context, name: str, enabled: bool) -> None:
"""Toggle silent mode (suppress status messages)."""
async with self.config.networks() as ns:
ns[name]["silent"] = enabled
await ctx.send(embed=ok_embed(f"Silent mode {'enabled' if enabled else 'disabled'} for `{name}`."))
@wh_set.command(name="embeds")
@requires_role(Role.ADMIN)
async def wh_set_embeds(self, ctx: commands.Context, name: str, enabled: bool) -> None:
"""Toggle forwarding embeds from relayed messages."""
async with self.config.networks() as ns:
ns[name]["forward_embeds"] = enabled
await ctx.send(embed=ok_embed(f"Embed forwarding {'enabled' if enabled else 'disabled'} for `{name}`."))
# ── Rate limiting ──────────────────────────────────────────────────────
@wh_set.command(name="ratelimit")
@requires_role(Role.ADMIN)
async def wh_set_ratelimit(self, ctx: commands.Context, name: str, rate: int = 5, per: float = 10.0) -> None:
"""Set rate limit: *rate* messages per *per* seconds."""
async with self.config.networks() as ns:
ns[name]["rate_limit_rate"] = rate
ns[name]["rate_limit_per"] = per
if name in self.cooldowns:
self.cooldowns[name].update(rate, per)
else:
from ..utils import CooldownBucket
self.cooldowns[name] = CooldownBucket(rate, per)
await ctx.send(embed=ok_embed(f"Rate limit: {rate} msgs per {per}s for `{name}`."))
@wh_set.command(name="slowmode")
@requires_role(Role.ADMIN)
async def wh_set_slowmode(self, ctx: commands.Context, name: str, seconds: int = 0) -> None:
"""Set slowmode (seconds between messages per user, 0 to disable)."""
async with self.config.networks() as ns:
ns[name]["slowmode"] = max(0, seconds)
await ctx.send(embed=ok_embed(f"Slowmode set to {seconds}s for `{name}`."))
# ── Log channel ────────────────────────────────────────────────────────
@wh_set.command(name="logchannel", aliases=["log"])
@requires_role(Role.ADMIN)
async def wh_set_logchannel(self, ctx: commands.Context, name: str, channel: discord.TextChannel = None) -> None:
"""Set (or clear) the mod log channel."""
async with self.config.networks() as ns:
ns[name]["log_channel"] = channel.id if channel else None
await ctx.send(embed=ok_embed(f"Log channel {'set to ' + channel.mention if channel else 'cleared'} for `{name}`."))
# ── Server nicknames ───────────────────────────────────────────────────
@wh_set.command(name="servernick")
@requires_role(Role.ADMIN)
async def wh_set_servernick(self, ctx: commands.Context, name: str, guild_id: int, *, nick: str = None) -> None:
"""Set or clear a nickname for a server in relay messages."""
async with self.config.networks() as ns:
nicks = ns[name].setdefault("server_nicknames", {})
if nick:
nicks[str(guild_id)] = nick
else:
nicks.pop(str(guild_id), None)
await ctx.send(embed=ok_embed(f"Server nickname {'set' if nick else 'cleared'}."))
# ── Relay delay ────────────────────────────────────────────────────────
@wh_set.command(name="delay")
@requires_role(Role.ADMIN)
async def wh_set_delay(self, ctx: commands.Context, name: str, seconds: int = 0) -> None:
"""Set relay delay (seconds before messages are relayed, 0 to disable)."""
async with self.config.networks() as ns:
ns[name]["relay_delay"] = max(0, seconds)
await ctx.send(embed=ok_embed(f"Relay delay set to {seconds}s for `{name}`."))
# ── Ephemeral auto-delete ──────────────────────────────────────────────
@wh_set.command(name="ephemeral")
@requires_role(Role.ADMIN)
async def wh_set_ephemeral(self, ctx: commands.Context, name: str, seconds: int = 0) -> None:
"""Auto-delete relayed messages after N seconds (0 to disable)."""
async with self.config.networks() as ns:
ns[name]["ephemeral_delay"] = max(0, seconds)
await ctx.send(embed=ok_embed(f"Ephemeral delay set to {seconds}s for `{name}`."))
# ── Discovery ──────────────────────────────────────────────────────────
@wh_set.command(name="public")
@requires_role(Role.OWNER)
async def wh_set_public(self, ctx: commands.Context, name: str, enabled: bool) -> None:
"""Make network publicly discoverable."""
async with self.config.networks() as ns:
ns[name]["public"] = enabled
await ctx.send(embed=ok_embed(f"`{name}` is now {'public' if enabled else 'private'}."))
@wh_set.command(name="tags")
@requires_role(Role.ADMIN)
async def wh_set_tags(self, ctx: commands.Context, name: str, *, tags: str) -> None:
"""Set discovery tags (comma-separated)."""
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
async with self.config.networks() as ns:
ns[name]["tags"] = tag_list
await ctx.send(embed=ok_embed(f"Tags set for `{name}`: {', '.join(tag_list)}"))
# ── Channel overrides ──────────────────────────────────────────────────
@wh_set.command(name="override")
@requires_role(Role.ADMIN)
async def wh_set_override(self, ctx: commands.Context, name: str, key: str, value: str) -> None:
"""Set a per-channel config override for the current channel."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
if ctx.channel.id not in nd.get("channels", []):
return await ctx.send(embed=err_embed("This channel isn't in the network."))
# Try to parse value
parsed = value
if value.lower() in ("true", "false"):
parsed = value.lower() == "true"
else:
try:
parsed = int(value)
except ValueError:
try:
parsed = float(value)
except ValueError:
pass
async with self.config.networks() as ns:
overrides = ns[name].setdefault("channel_overrides", {})
overrides.setdefault(str(ctx.channel.id), {})[key] = parsed
await ctx.send(embed=ok_embed(f"Override `{key} = {parsed}` set for this channel in `{name}`."))
# ── View all settings ──────────────────────────────────────────────────
@wh_set.command(name="show")
@requires_role(Role.HELPER)
async def wh_set_show(self, ctx: commands.Context, name: str) -> None:
"""Show current settings for a network."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
em = discord.Embed(title=f"⚙️ Settings — {name}", colour=COLOUR_INFO)
em.add_field(name="Relay", value=(
f"Mode: {nd.get('relay_mode', 'webhook')}\n"
f"Image: {nd.get('image_mode', 'user')}\n"
f"Name: {nd.get('name_mode', 'both')}\n"
f"Frozen: {nd.get('frozen', False)}\n"
f"Anonymous: {nd.get('anonymous', False)}"
), inline=True)
em.add_field(name="Sync", value=(
f"Edits: {nd.get('sync_edits')}\n"
f"Deletes: {nd.get('sync_deletes')}\n"
f"Reactions: {nd.get('sync_reactions')}\n"
f"Replies: {nd.get('sync_replies')}\n"
f"Typing: {nd.get('sync_typing', False)}"
), inline=True)
em.add_field(name="Limits", value=(
f"Rate: {nd.get('rate_limit_rate', 5)}/{nd.get('rate_limit_per', 10)}s\n"
f"Slowmode: {nd.get('slowmode', 0)}s\n"
f"Delay: {nd.get('relay_delay', 0)}s\n"
f"Ephemeral: {nd.get('ephemeral_delay', 0)}s"
), inline=True)
am = nd.get("automod", {})
if am.get("enabled"):
active = [k.replace("anti_", "") for k, v in am.items() if k.startswith("anti_") and v]
em.add_field(name="Automod", value=", ".join(active) or "None active", inline=False)
await ctx.send(embed=em)

189
wormhole/commands/social.py Normal file
View file

@ -0,0 +1,189 @@
"""Social commands — karma, starboard, highlights, bookmarks, profiles."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
import discord
from redbot.core import commands
from ..models.permissions import Role, has_role, requires_role, get_role, role_name
from ..utils import ok_embed, err_embed, info_embed, star_embed, warn_embed, truncate, COLOUR_INFO, COLOUR_STAR
class SocialCommands:
"""Mixin — social features."""
# ── Profiles ───────────────────────────────────────────────────────────
@commands.hybrid_command(name="wh-profile")
async def wh_profile(self, ctx: commands.Context, name: str, user: discord.User = None) -> None:
"""View a user's wormhole profile in a network."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
target = user or ctx.author
p = nd.get("user_profiles", {}).get(str(target.id))
if not p:
return await ctx.send(embed=info_embed(f"{target.display_name} hasn't been seen in `{name}`."))
role = get_role(nd, target.id)
karma = nd.get("karma_scores", {}).get(str(target.id), 0)
em = discord.Embed(
title=f"👤 {target.display_name}",
colour=COLOUR_INFO,
)
em.set_thumbnail(url=target.display_avatar.url)
em.add_field(name="Role", value=role_name(role), inline=True)
em.add_field(name="Messages", value=f"{p.get('message_count', 0):,}", inline=True)
em.add_field(name="Karma", value=str(karma), inline=True)
em.add_field(name="First Seen", value=p.get("first_seen", "Unknown")[:10], inline=True)
em.add_field(name="Servers", value=str(len(p.get("servers", []))), inline=True)
em.set_footer(text=f"Network: {name}")
await ctx.send(embed=em)
# ── Karma ──────────────────────────────────────────────────────────────
@commands.group(name="wh-karma", aliases=["whkarma"], invoke_without_command=True)
async def wh_karma(self, ctx: commands.Context) -> None:
"""Karma system commands."""
await ctx.send_help(ctx.command)
@wh_karma.command(name="enable")
@requires_role(Role.ADMIN)
async def wh_karma_enable(self, ctx: commands.Context, name: str, enabled: bool = True) -> None:
"""Enable or disable the karma system."""
async with self.config.networks() as ns:
ns[name]["karma_enabled"] = enabled
await ctx.send(embed=ok_embed(f"Karma {'enabled' if enabled else 'disabled'} for `{name}`."))
@wh_karma.command(name="emoji")
@requires_role(Role.ADMIN)
async def wh_karma_emoji(self, ctx: commands.Context, name: str, emoji: str = "👍") -> None:
"""Set the karma reaction emoji."""
async with self.config.networks() as ns:
ns[name]["karma_emoji"] = emoji
await ctx.send(embed=ok_embed(f"Karma emoji set to {emoji} for `{name}`."))
@wh_karma.command(name="top")
async def wh_karma_top(self, ctx: commands.Context, name: str, count: int = 10) -> None:
"""Show karma leaderboard."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
scores = nd.get("karma_scores", {})
if not scores:
return await ctx.send(embed=info_embed("No karma data yet."))
sorted_scores = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:count]
lines = []
medals = ["🥇", "🥈", "🥉"]
for i, (uid_str, score) in enumerate(sorted_scores):
user = self.bot.get_user(int(uid_str))
medal = medals[i] if i < 3 else f"**{i+1}.**"
lines.append(f"{medal} {user or uid_str} — **{score}** karma")
em = discord.Embed(title=f"🏆 Karma Leaderboard — {name}", description="\n".join(lines), colour=COLOUR_INFO)
await ctx.send(embed=em)
# ── Starboard ──────────────────────────────────────────────────────────
@commands.group(name="wh-star", aliases=["whstar"], invoke_without_command=True)
async def wh_star(self, ctx: commands.Context) -> None:
"""Starboard commands."""
await ctx.send_help(ctx.command)
@wh_star.command(name="enable")
@requires_role(Role.ADMIN)
async def wh_star_enable(self, ctx: commands.Context, name: str, enabled: bool = True) -> None:
"""Enable or disable starboard."""
async with self.config.networks() as ns:
ns[name]["starboard_enabled"] = enabled
await ctx.send(embed=ok_embed(f"Starboard {'enabled' if enabled else 'disabled'} for `{name}`."))
@wh_star.command(name="channel")
@requires_role(Role.ADMIN)
async def wh_star_channel(self, ctx: commands.Context, name: str, channel: discord.TextChannel = None) -> None:
"""Set the starboard channel."""
async with self.config.networks() as ns:
ns[name]["starboard_channel"] = channel.id if channel else None
await ctx.send(embed=ok_embed(f"Starboard channel {'set' if channel else 'cleared'} for `{name}`."))
@wh_star.command(name="threshold")
@requires_role(Role.ADMIN)
async def wh_star_threshold(self, ctx: commands.Context, name: str, threshold: int = 3) -> None:
"""Set minimum stars to reach the starboard."""
async with self.config.networks() as ns:
ns[name]["starboard_threshold"] = max(1, threshold)
await ctx.send(embed=ok_embed(f"Starboard threshold set to {threshold} for `{name}`."))
# ── Highlights ─────────────────────────────────────────────────────────
@commands.group(name="wh-hl", aliases=["whhl"], invoke_without_command=True)
async def wh_hl(self, ctx: commands.Context) -> None:
"""Keyword highlight notifications."""
await ctx.send_help(ctx.command)
@wh_hl.command(name="add")
async def wh_hl_add(self, ctx: commands.Context, name: str, *, keyword: str) -> None:
"""Add a keyword to highlight."""
async with self.config.networks() as ns:
hl = ns[name].setdefault("highlights", {})
uid_str = str(ctx.author.id)
user_hl = hl.setdefault(uid_str, [])
if keyword.lower() in [k.lower() for k in user_hl]:
return await ctx.send(embed=err_embed("Keyword already highlighted."))
if len(user_hl) >= 25:
return await ctx.send(embed=err_embed("Max 25 highlights per user."))
user_hl.append(keyword)
await ctx.send(embed=ok_embed(f"Highlight `{keyword}` added for `{name}`."))
@wh_hl.command(name="remove", aliases=["rm"])
async def wh_hl_remove(self, ctx: commands.Context, name: str, *, keyword: str) -> None:
"""Remove a highlight keyword."""
async with self.config.networks() as ns:
hl = ns[name].get("highlights", {})
user_hl = hl.get(str(ctx.author.id), [])
ns[name]["highlights"][str(ctx.author.id)] = [k for k in user_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_list(self, ctx: commands.Context, name: str) -> None:
"""List your highlights."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
user_hl = nd.get("highlights", {}).get(str(ctx.author.id), [])
if not user_hl:
return await ctx.send(embed=info_embed("No highlights set."))
await ctx.send(embed=info_embed("\n".join(f"• `{k}`" for k in user_hl), title="🔔 Your Highlights"))
# ── Bookmarks ──────────────────────────────────────────────────────────
@commands.group(name="wh-bm", aliases=["whbm"], invoke_without_command=True)
async def wh_bm(self, ctx: commands.Context) -> None:
"""Message bookmarks."""
await ctx.send_help(ctx.command)
@wh_bm.command(name="list")
async def wh_bm_list(self, ctx: commands.Context) -> None:
"""List your bookmarks."""
bm = await self.config.bookmarks()
user_bm = bm.get(str(ctx.author.id), [])
if not user_bm:
return await ctx.send(embed=info_embed("No bookmarks yet. Use the context menu to bookmark!"))
lines = []
for b in reversed(user_bm[-15:]):
lines.append(
f"• [{b.get('timestamp', '?')[:10]}] **{b.get('author', '?')}** in {b.get('server', '?')}\n"
f" {truncate(b.get('content', ''), 80)} — [Jump]({b.get('jump_url', '#')})"
)
em = discord.Embed(title="📌 Bookmarks", description="\n".join(lines), colour=COLOUR_INFO)
await ctx.send(embed=em)
@wh_bm.command(name="clear")
async def wh_bm_clear(self, ctx: commands.Context) -> None:
"""Clear all your bookmarks."""
async with self.config.bookmarks() as bm:
bm.pop(str(ctx.author.id), None)
await ctx.send(embed=ok_embed("All bookmarks cleared."))

194
wormhole/commands/staff.py Normal file
View file

@ -0,0 +1,194 @@
"""Staff management commands — assign roles, list staff, transfer ownership."""
from __future__ import annotations
import discord
from redbot.core import commands
from ..models.permissions import (
Role,
can_assign,
get_role,
has_role,
list_staff,
requires_role,
role_from_str,
role_name,
set_staff_role,
remove_staff,
)
from ..utils import ok_embed, err_embed, info_embed, COLOUR_INFO
class StaffCommands:
"""Mixin — staff management (per-network hierarchy)."""
@commands.group(name="wh-staff", aliases=["whstaff"], invoke_without_command=True)
async def wh_staff(self, ctx: commands.Context) -> None:
"""Manage network staff — add, remove, list, promote, demote."""
await ctx.send_help(ctx.command)
@wh_staff.command(name="add")
async def wh_staff_add(self, ctx: commands.Context, name: str, user: discord.User, role_str: str = "moderator") -> None:
"""Assign a staff role to a user.
Roles: ``admin``, ``moderator``, ``helper``
"""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
target_role = role_from_str(role_str)
if target_role is None or target_role <= Role.MEMBER or target_role >= Role.OWNER:
return await ctx.send(embed=err_embed("Valid roles: `admin`, `moderator`, `helper`."))
actor_role = get_role(nd, ctx.author.id)
is_bot_owner = await self.bot.is_owner(ctx.author)
if not is_bot_owner and not can_assign(actor_role, target_role):
return await ctx.send(embed=err_embed(
f"You need a higher role than **{role_name(target_role)}** to assign it.\n"
f"Your role: **{role_name(actor_role)}**"
))
async with self.config.networks() as ns:
set_staff_role(ns[name], user.id, target_role)
await ctx.send(embed=ok_embed(
f"{user.mention} is now **{role_name(target_role)}** in `{name}`."
))
await self._audit(name, "staff_add", str(ctx.author), str(user), f"role={role_name(target_role)}")
@wh_staff.command(name="remove", aliases=["rm"])
async def wh_staff_rm(self, ctx: commands.Context, name: str, user: discord.User) -> None:
"""Remove a user from network staff."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
target_role = get_role(nd, user.id)
if target_role <= Role.MEMBER:
return await ctx.send(embed=err_embed(f"{user} isn't staff in `{name}`."))
actor_role = get_role(nd, ctx.author.id)
is_bot_owner = await self.bot.is_owner(ctx.author)
if not is_bot_owner and not can_assign(actor_role, target_role):
return await ctx.send(embed=err_embed(
f"You can't remove a **{role_name(target_role)}** — "
f"your role: **{role_name(actor_role)}**"
))
async with self.config.networks() as ns:
remove_staff(ns[name], user.id)
await ctx.send(embed=ok_embed(f"{user.mention} removed from staff in `{name}`."))
await self._audit(name, "staff_rm", str(ctx.author), str(user))
@wh_staff.command(name="promote")
async def wh_staff_promote(self, ctx: commands.Context, name: str, user: discord.User) -> None:
"""Promote a staff member one tier up."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
current = get_role(nd, user.id)
if current >= Role.ADMIN:
return await ctx.send(embed=err_embed(f"{user} is already **{role_name(current)}** — can't promote further."))
new_role = Role(current + 1)
if new_role >= Role.OWNER:
return await ctx.send(embed=err_embed("Can't promote to Owner. Use `wh transfer` instead."))
actor_role = get_role(nd, ctx.author.id)
is_bot_owner = await self.bot.is_owner(ctx.author)
if not is_bot_owner and not can_assign(actor_role, new_role):
return await ctx.send(embed=err_embed(
f"You can't assign **{role_name(new_role)}** — "
f"your role: **{role_name(actor_role)}**"
))
async with self.config.networks() as ns:
set_staff_role(ns[name], user.id, new_role)
await ctx.send(embed=ok_embed(
f"{user.mention} promoted: **{role_name(current)}** → **{role_name(new_role)}** in `{name}`."
))
await self._audit(name, "promote", str(ctx.author), str(user), f"{role_name(current)} → {role_name(new_role)}")
@wh_staff.command(name="demote")
async def wh_staff_demote(self, ctx: commands.Context, name: str, user: discord.User) -> None:
"""Demote a staff member one tier down."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
current = get_role(nd, user.id)
if current <= Role.MEMBER:
return await ctx.send(embed=err_embed(f"{user} isn't staff in `{name}`."))
if current >= Role.OWNER:
return await ctx.send(embed=err_embed("Can't demote the Owner. Use `wh transfer` first."))
new_role = Role(current - 1)
actor_role = get_role(nd, ctx.author.id)
is_bot_owner = await self.bot.is_owner(ctx.author)
if not is_bot_owner and not can_assign(actor_role, current):
return await ctx.send(embed=err_embed(
f"You can't demote a **{role_name(current)}** — "
f"your role: **{role_name(actor_role)}**"
))
async with self.config.networks() as ns:
if new_role <= Role.MEMBER:
remove_staff(ns[name], user.id)
else:
set_staff_role(ns[name], user.id, new_role)
label = role_name(new_role) if new_role > Role.MEMBER else "Member (removed from staff)"
await ctx.send(embed=ok_embed(
f"{user.mention} demoted: **{role_name(current)}** → **{label}** in `{name}`."
))
await self._audit(name, "demote", str(ctx.author), str(user), f"{role_name(current)} → {label}")
@wh_staff.command(name="list", aliases=["ls"])
async def wh_staff_ls(self, ctx: commands.Context, name: str) -> None:
"""List all staff in a network with their roles."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
staff = list_staff(nd)
if not staff:
return await ctx.send(embed=info_embed(f"No staff in `{name}`."))
lines = []
for uid, role in sorted(staff.items(), key=lambda x: -x[1]):
user = self.bot.get_user(uid)
display = str(user) if user else f"Unknown ({uid})"
emoji = {Role.OWNER: "👑", Role.ADMIN: "🛡️", Role.MODERATOR: "⚔️", Role.HELPER: "🤝"}.get(role, "")
lines.append(f"{emoji} **{role_name(role)}** — {display}")
em = discord.Embed(
title=f"👥 Staff — {name}",
description="\n".join(lines),
colour=COLOUR_INFO,
)
await ctx.send(embed=em)
@commands.hybrid_command(name="wh-transfer")
async def wh_transfer(self, ctx: commands.Context, name: str, new_owner: discord.User) -> None:
"""Transfer network ownership (owner or bot owner only)."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
if not has_role(nd, ctx.author.id, Role.OWNER) and not await self.bot.is_owner(ctx.author):
return await ctx.send(embed=err_embed("Only the network owner or bot owner can transfer ownership."))
old_owner = nd["owner_id"]
async with self.config.networks() as ns:
ns[name]["owner_id"] = new_owner.id
# Demote old owner to Admin if they're not the bot owner
if old_owner != new_owner.id:
set_staff_role(ns[name], old_owner, Role.ADMIN)
# Remove new owner from staff dict (they're owner now)
remove_staff(ns[name], new_owner.id)
await ctx.send(embed=ok_embed(
f"Ownership of `{name}` transferred to {new_owner.mention}.\n"
f"Previous owner demoted to Admin."
))
await self._audit(name, "transfer", str(ctx.author), str(new_owner))

83
wormhole/commands/tos.py Normal file
View file

@ -0,0 +1,83 @@
"""ToS / Rules acceptance gate commands."""
from __future__ import annotations
from datetime import datetime, timezone
import discord
from redbot.core import commands
from ..models.permissions import Role, requires_role
from ..utils import ok_embed, err_embed, info_embed, COLOUR_INFO
class ToSCommands:
"""Mixin — Terms of Service / rules acceptance gate."""
@commands.group(name="wh-rules", aliases=["whrules"], invoke_without_command=True)
async def wh_rules(self, ctx: commands.Context) -> None:
"""Network rules / Terms of Service."""
await ctx.send_help(ctx.command)
@wh_rules.command(name="set")
@requires_role(Role.ADMIN)
async def wh_rules_set(self, ctx: commands.Context, name: str, *, text: str) -> None:
"""Set the rules / ToS text."""
async with self.config.networks() as ns:
ns[name]["rules_text"] = text
await ctx.send(embed=ok_embed(f"Rules updated for `{name}`."))
@wh_rules.command(name="require")
@requires_role(Role.ADMIN)
async def wh_rules_require(self, ctx: commands.Context, name: str, enabled: bool = True) -> None:
"""Toggle mandatory rules acceptance before using the network."""
async with self.config.networks() as ns:
ns[name]["rules_required"] = enabled
await ctx.send(embed=ok_embed(
f"Rules acceptance {'required' if enabled else 'not required'} for `{name}`."
))
@wh_rules.command(name="view")
async def wh_rules_view(self, ctx: commands.Context, name: str) -> None:
"""View the network rules."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
text = nd.get("rules_text") or nd.get("rules") or "No rules set."
em = discord.Embed(
title=f"📜 Rules — {name}",
description=text[:4000],
colour=COLOUR_INFO,
)
accepted = nd.get("rules_accepted", {}).get(str(ctx.author.id))
if accepted:
em.set_footer(text=f"You accepted on {accepted[:10]}")
else:
em.set_footer(text="⚠️ You haven't accepted these rules yet. Use `wh accept` or `wh agree`.")
await ctx.send(embed=em)
@commands.hybrid_command(name="wh-accept")
async def wh_accept(self, ctx: commands.Context, name: str) -> None:
"""Accept the network rules / ToS."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
if not nd.get("rules_required"):
return await ctx.send(embed=info_embed("This network doesn't require rules acceptance."))
async with self.config.networks() as ns:
ns[name].setdefault("rules_accepted", {})[str(ctx.author.id)] = datetime.now(timezone.utc).isoformat()
await ctx.send(embed=ok_embed(f"You've accepted the rules for `{name}`. You can now use the relay."))
@commands.hybrid_command(name="wh-agree")
async def wh_agree(self, ctx: commands.Context, name: str) -> None:
"""Alias for ``wh accept``."""
await self.wh_accept(ctx, name)
@wh_rules.command(name="reset")
@requires_role(Role.ADMIN)
async def wh_rules_reset(self, ctx: commands.Context, name: str) -> None:
"""Reset all acceptances (force everyone to re-accept)."""
async with self.config.networks() as ns:
ns[name]["rules_accepted"] = {}
await ctx.send(embed=ok_embed(f"All acceptances reset for `{name}`. Users must re-accept."))
await self._audit(name, "rules_reset", str(ctx.author))

779
wormhole/core.py Normal file
View file

@ -0,0 +1,779 @@
"""
Wormhole v4.0.0 — Core cog class, config, and internal helpers.
================================================================
This is the main cog class that Red loads. Commands are added via
mixin classes in the ``commands/`` package; event listeners live in
``listeners/``. This file contains:
- Cog initialisation and config registration
- Internal helper methods (_net, _save, _wh, _log, _audit, …)
- Background task management
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import random
import time
from collections import defaultdict
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Dict, List, Optional, Set
import discord
from discord import app_commands
from redbot.core import Config, checks, commands
from redbot.core.bot import Red
from .models.config import DEFAULT_GLOBAL, DEFAULT_NETWORK
from .models.message_map import MessageMap
from .models.permissions import Role, has_role, get_role, role_name
from .ui.modals import ReportModal
from .ui.views import reply_jump_view
from .services.emoji import resolve_foreign_emojis, build_emoji_embeds_and_files
from .utils import (
BLOCKED_EXTENSIONS,
COLOUR_ANNOUNCE,
COLOUR_DM,
COLOUR_INFO,
COLOUR_NEUTRAL,
COLOUR_OK,
COLOUR_STAR,
CooldownBucket,
DuplicateDetector,
RaidDetector,
announce_embed,
apply_mention_policy,
build_dm_incoming_embed,
build_dm_relay_embed,
build_portal_embed,
build_relay_embed,
build_star_embed,
check_attachment_filters,
check_automod,
check_filters,
compact_format,
dm_embed,
err_embed,
format_audit_entry,
generate_invite_code,
human_timedelta,
info_embed,
ok_embed,
sanitise_mentions,
star_embed,
truncate,
warn_embed,
)
# Command mixins
from .commands.network import NetworkCommands
from .commands.settings import SettingsCommands
from .commands.moderation import ModerationCommands
from .commands.staff import StaffCommands
from .commands.filters import FilterCommands
from .commands.social import SocialCommands
from .commands.dm import DMCommands
from .commands.advanced import AdvancedCommands
from .commands.mentions import MentionCommands
from .commands.tos import ToSCommands
from .commands.reports import ReportCommands
from .commands.bridge import BridgeCommands
from .commands.debug import DebugCommands
# Listener mixins
from .listeners.relay import RelayListener
from .listeners.sync import SyncListener
from .listeners.misc import MiscListener
log = logging.getLogger("red.evecogs.wormhole")
_AUDIT_LIMIT = 500
class Wormhole(
# Listeners
RelayListener,
SyncListener,
MiscListener,
# Commands
NetworkCommands,
SettingsCommands,
ModerationCommands,
StaffCommands,
FilterCommands,
SocialCommands,
DMCommands,
AdvancedCommands,
MentionCommands,
ToSCommands,
ReportCommands,
BridgeCommands,
DebugCommands,
commands.Cog,
):
"""The ultimate cross-server relay cog for Red-DiscordBot."""
__version__ = "4.0.0"
def __init__(self, bot: Red) -> None:
self.bot = bot
self.config = Config.get_conf(self, identifier=928374650, force_registration=True)
self.config.register_global(**DEFAULT_GLOBAL)
# In-memory state
self.msg_map = MessageMap()
self.cooldowns: Dict[str, CooldownBucket] = {}
self.dup_detectors: Dict[str, DuplicateDetector] = {}
self.raid_detectors: Dict[str, RaidDetector] = {}
self.slowmode_tracker: Dict[str, Dict[int, float]] = {}
self._wh_cache: Dict[int, discord.Webhook] = {}
self._ready = asyncio.Event()
self._bg_tasks: List[asyncio.Task] = []
self._trace_channels: Set[int] = set()
async def _init(self) -> None:
"""Deferred initialisation — called from setup() after cog is added."""
await self.bot.wait_until_ready()
nets = await self.config.networks()
for name, nd in nets.items():
r = nd.get("rate_limit_rate", 5)
p = nd.get("rate_limit_per", 10.0)
self.cooldowns[name] = CooldownBucket(r, p)
am = nd.get("automod", {})
if am.get("anti_spam"):
self.dup_detectors[name] = DuplicateDetector(
am.get("spam_window", 30.0), am.get("spam_threshold", 3)
)
if am.get("anti_raid"):
self.raid_detectors[name] = RaidDetector(
am.get("raid_window", 60.0), am.get("raid_threshold", 10)
)
self._bg_tasks.append(asyncio.create_task(self._blackout_loop()))
self._bg_tasks.append(asyncio.create_task(self._portal_update_loop()))
self._bg_tasks.append(asyncio.create_task(self._scheduled_msg_loop()))
self._bg_tasks.append(asyncio.create_task(self._health_check_loop()))
self._bg_tasks.append(asyncio.create_task(self._poll_expiry_loop()))
self._register_context_menus()
self._ready.set()
log.info("Wormhole v%s ready — %d networks loaded", self.__version__, len(nets))
# ── Context menu registration ──────────────────────────────────────────
def _register_context_menus(self) -> None:
"""Register slash commands and context menus."""
# ── Context menus ──
ctx_report = app_commands.ContextMenu(name="Report to Wormhole", callback=self._ctx_report_message)
ctx_bookmark = app_commands.ContextMenu(name="Bookmark (Wormhole)", callback=self._ctx_bookmark_message)
ctx_delete = app_commands.ContextMenu(name="Delete from Network", callback=self._ctx_delete_message)
ctx_profile = app_commands.ContextMenu(name="Wormhole Profile", callback=self._ctx_view_profile)
self.bot.tree.add_command(ctx_report)
self.bot.tree.add_command(ctx_bookmark)
self.bot.tree.add_command(ctx_delete)
self.bot.tree.add_command(ctx_profile)
# ── Slash commands ──
@app_commands.command(name="wh-list", description="List wormhole networks")
async def slash_list(interaction: discord.Interaction):
ctx = await commands.Context.from_interaction(interaction)
await self.wh_list(ctx)
@app_commands.command(name="wh-info", description="Info about a network")
@app_commands.describe(name="Network name")
async def slash_info(interaction: discord.Interaction, name: str):
ctx = await commands.Context.from_interaction(interaction)
await self.wh_info(ctx, name)
@app_commands.command(name="wh-accept", description="Accept network rules")
@app_commands.describe(name="Network name")
async def slash_accept(interaction: discord.Interaction, name: str):
ctx = await commands.Context.from_interaction(interaction)
await self.wh_accept(ctx, name)
@app_commands.command(name="wh-agree", description="Agree to network ToS")
@app_commands.describe(name="Network name")
async def slash_agree(interaction: discord.Interaction, name: str):
ctx = await commands.Context.from_interaction(interaction)
await self.wh_agree(ctx, name)
@app_commands.command(name="wh-report", description="Report a message")
@app_commands.describe(message_id="Message ID to report", reason="Reason")
async def slash_report(interaction: discord.Interaction, message_id: str, reason: str = "No reason provided"):
ctx = await commands.Context.from_interaction(interaction)
await self.wh_report_msg(ctx, int(message_id), reason=reason)
@app_commands.command(name="wh-profile", description="View a user's wormhole profile")
@app_commands.describe(name="Network name", user="User to look up")
async def slash_profile(interaction: discord.Interaction, name: str, user: discord.User = None):
ctx = await commands.Context.from_interaction(interaction)
await self.wh_profile(ctx, name, user)
@app_commands.command(name="wh-discover", description="Discover public networks")
async def slash_discover(interaction: discord.Interaction):
ctx = await commands.Context.from_interaction(interaction)
await self.wh_discover(ctx)
for cmd in (slash_list, slash_info, slash_accept, slash_agree,
slash_report, slash_profile, slash_discover):
self.bot.tree.add_command(cmd)
async def cog_unload(self) -> None:
for task in self._bg_tasks:
task.cancel()
for cmd_name in ("Report to Wormhole", "Bookmark (Wormhole)",
"Delete from Network", "Wormhole Profile",
"wh-list", "wh-info", "wh-accept", "wh-agree",
"wh-report", "wh-profile", "wh-discover"):
self.bot.tree.remove_command(cmd_name)
# ── Background task loops ──────────────────────────────────────────────
async def _blackout_loop(self) -> None:
await self.bot.wait_until_ready()
while True:
try:
now = datetime.now(timezone.utc)
async with self.config.networks() as nets:
for name, nd in nets.items():
for sched in nd.get("blackout_schedules", []):
day = now.weekday()
if day not in sched.get("days", []):
continue
h = now.hour
start, end = sched["start_hour"], sched["end_hour"]
in_window = (
(start <= h < end) if start < end else (h >= start or h < end)
)
if in_window and not nd.get("frozen"):
nd["frozen"] = True
log.info("Blackout freeze: %s", name)
elif not in_window and nd.get("frozen") and nd.get("_blackout_froze"):
nd["frozen"] = False
nd.pop("_blackout_froze", None)
if in_window:
nd["_blackout_froze"] = True
except Exception as exc:
log.error("Blackout loop error: %s", exc)
await asyncio.sleep(60)
async def _portal_update_loop(self) -> None:
await self.bot.wait_until_ready()
while True:
try:
nets = await self.config.networks()
for name, nd in nets.items():
for ch_id_s, msg_id in list(nd.get("portal_messages", {}).items()):
ch = self.bot.get_channel(int(ch_id_s))
if not ch:
continue
try:
msg = await ch.fetch_message(msg_id)
em = build_portal_embed(
name, nd, len(nd.get("channels", [])),
nd.get("total_messages", 0),
)
await msg.edit(embed=em)
except Exception:
pass
except Exception as exc:
log.error("Portal loop error: %s", exc)
await asyncio.sleep(300)
async def _scheduled_msg_loop(self) -> None:
await self.bot.wait_until_ready()
while True:
try:
now = datetime.now(timezone.utc)
async with self.config.networks() as nets:
for name, nd in nets.items():
remaining = []
for sm in nd.get("scheduled_messages", []):
send_at = datetime.fromisoformat(sm["send_at_iso"])
if now >= send_at:
for ch_id in nd.get("channels", []):
ch = self.bot.get_channel(ch_id)
if ch:
try:
await ch.send(
embed=announce_embed(
sm["content"],
title=f"📅 Scheduled — {name}",
)
)
except Exception:
pass
else:
remaining.append(sm)
nd["scheduled_messages"] = remaining
except Exception as exc:
log.error("Scheduled msg loop error: %s", exc)
await asyncio.sleep(30)
async def _health_check_loop(self) -> None:
await self.bot.wait_until_ready()
while True:
try:
async with self.config.networks() as nets:
for name, nd in nets.items():
unhealthy = []
for ch_id in nd.get("channels", []):
ch = self.bot.get_channel(ch_id)
if not ch:
unhealthy.append(ch_id)
continue
perms = ch.permissions_for(ch.guild.me)
if not perms.send_messages or not perms.manage_webhooks:
unhealthy.append(ch_id)
nd["unhealthy_channels"] = unhealthy
nd["last_health_check"] = datetime.now(timezone.utc).isoformat()
except Exception as exc:
log.error("Health check error: %s", exc)
await asyncio.sleep(600)
async def _poll_expiry_loop(self) -> None:
await self.bot.wait_until_ready()
while True:
try:
now = datetime.now(timezone.utc)
async with self.config.networks() as nets:
for name, nd in nets.items():
expired = []
for pid, poll in list(nd.get("active_polls", {}).items()):
exp = datetime.fromisoformat(poll["expires"])
if now >= exp:
expired.append(pid)
results = self._format_poll_results(poll)
for ch_id in nd.get("channels", []):
ch = self.bot.get_channel(ch_id)
if ch:
try:
await ch.send(
embed=info_embed(results, title=f"📊 Poll Ended: {poll['question']}")
)
except Exception:
pass
for pid in expired:
nd.get("active_polls", {}).pop(pid, None)
except Exception as exc:
log.error("Poll expiry error: %s", exc)
await asyncio.sleep(30)
@staticmethod
def _format_poll_results(poll: dict) -> str:
options = poll.get("options", [])
votes = poll.get("votes", {})
lines = []
total = sum(len(v) for v in votes.values())
emojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"]
for i, opt in enumerate(options):
count = len(votes.get(str(i), []))
pct = (count / total * 100) if total else 0
bar = "█" * int(pct / 5) + "░" * (20 - int(pct / 5))
emoji = emojis[i] if i < len(emojis) else f"{i+1}."
lines.append(f"{emoji} **{opt}** — {count} votes ({pct:.0f}%)\n`{bar}`")
lines.append(f"\n**Total votes:** {total}")
return "\n".join(lines)
# ── Config helpers ─────────────────────────────────────────────────────
async def _net(self, name: str) -> Optional[dict]:
nets = await self.config.networks()
return nets.get(name)
async def _save(self, name: str, data: dict) -> None:
async with self.config.networks() as nets:
nets[name] = data
async def _net_for_ch(self, ch_id: int) -> Optional[str]:
nets = await self.config.networks()
for n, d in nets.items():
if ch_id in d.get("channels", []):
return n
return None
# ── Webhook helper ─────────────────────────────────────────────────────
async def _wh(self, ch: discord.TextChannel, *, force_refresh: bool = False) -> discord.Webhook:
if not force_refresh and ch.id in self._wh_cache:
return self._wh_cache[ch.id]
hooks = await ch.webhooks()
wh = discord.utils.get(hooks, name="Wormhole")
if not wh:
wh = await ch.create_webhook(name="Wormhole")
self._wh_cache[ch.id] = wh
return wh
# ── Identity helpers ───────────────────────────────────────────────────
def _avatar(self, msg: discord.Message, mode: str, icon: Optional[str]) -> str:
if mode == "server" and msg.guild and msg.guild.icon:
return msg.guild.icon.url
if mode == "custom" and icon:
return icon
return msg.author.display_avatar.url
def _name(self, msg: discord.Message, mode: str, custom: Optional[str], nick: Optional[str] = None) -> str:
server = nick or msg.guild.name
user = msg.author.display_name
if mode == "server":
return server
if mode == "custom" and custom:
return custom
if mode == "user":
return user
return f"{user} @ {server}"
# ── Anonymous mode ─────────────────────────────────────────────────────
def _anon_name(self, net_data: dict, user_id: int) -> str:
salt = net_data.get("anon_salt", "")
h = hashlib.md5(f"{salt}{user_id}".encode()).hexdigest()[:6]
return f"Anon-{h}"
def _anon_avatar(self, user_id: int) -> str:
idx = user_id % 5
return f"https://cdn.discordapp.com/embed/avatars/{idx}.png"
# ── Logging / audit ────────────────────────────────────────────────────
async def _log(self, d: dict, embed: discord.Embed) -> None:
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: str, action: str, user: str, target: str = "", details: str = "") -> None:
entry = format_audit_entry(action, user, target, details)
async with self.config.networks() as nets:
if name in nets:
log_list = nets[name].setdefault("audit_log", [])
log_list.append(entry)
if len(log_list) > _AUDIT_LIMIT:
nets[name]["audit_log"] = log_list[-_AUDIT_LIMIT:]
async def _status(self, net_name: str, net_data: dict, src_ch: Optional[int], text: str) -> None:
for ch_id in net_data.get("channels", []):
if ch_id == src_ch:
continue
ch = self.bot.get_channel(ch_id)
if ch:
try:
await ch.send(embed=info_embed(text, title=f"🌀 {net_name}"))
except Exception:
pass
# ── Profile tracking ───────────────────────────────────────────────────
async def _update_profile(self, name: str, user: discord.User, guild_id: int) -> None:
async with self.config.networks() as nets:
if name not in nets:
return
profiles = nets[name].setdefault("user_profiles", {})
uid = str(user.id)
if uid not in profiles:
profiles[uid] = {
"display_name": user.display_name,
"avatar_url": str(user.display_avatar.url),
"first_seen": datetime.now(timezone.utc).isoformat(),
"message_count": 0,
"servers": [],
}
profiles[uid]["message_count"] = profiles[uid].get("message_count", 0) + 1
profiles[uid]["display_name"] = user.display_name
profiles[uid]["avatar_url"] = str(user.display_avatar.url)
if guild_id not in profiles[uid].get("servers", []):
profiles[uid].setdefault("servers", []).append(guild_id)
# ── Highlight notifications ────────────────────────────────────────────
async def _check_highlights(self, net_name: str, net_data: dict, content: str, author_id: int) -> None:
if not content:
return
lowered = content.lower()
for uid_str, keywords in net_data.get("highlights", {}).items():
uid = int(uid_str)
if uid == author_id:
continue
for kw in keywords:
if kw.lower() in lowered:
user = self.bot.get_user(uid)
if user:
try:
await user.send(
embed=info_embed(
f"Keyword **{kw}** mentioned in `{net_name}`:\n>>> {truncate(content, 200)}",
title="🔔 Highlight",
)
)
except discord.Forbidden:
pass
break
# ── AFK system ─────────────────────────────────────────────────────────
async def _check_afk(self, net_name: str, net_data: dict, message: discord.Message) -> None:
afk = net_data.get("afk_users", {})
uid_str = str(message.author.id)
# Return from AFK
if uid_str in afk:
since = afk[uid_str].get("since_iso", "")
async with self.config.networks() as nets:
if net_name in nets:
nets[net_name].get("afk_users", {}).pop(uid_str, None)
try:
await message.channel.send(
embed=info_embed(f"Welcome back, {message.author.mention}! AFK removed."),
delete_after=10,
)
except Exception:
pass
# Notify about mentioned AFK users
if message.mentions:
for mentioned in message.mentions:
muid = str(mentioned.id)
if muid in afk:
reason = afk[muid].get("reason", "AFK")
try:
await message.channel.send(
embed=info_embed(
f"{mentioned.display_name} is AFK: **{reason}**"
),
delete_after=10,
)
except Exception:
pass
# ── Auto-responses ─────────────────────────────────────────────────────
async def _check_auto_responses(self, net_name: str, net_data: dict, message: discord.Message) -> None:
if not message.content:
return
ars = net_data.get("auto_responses", {})
now = time.time()
for trigger, cfg in ars.items():
is_regex = cfg.get("regex", False)
cooldown = cfg.get("cooldown", 0)
last_used = cfg.get("last_used", 0)
if cooldown and (now - last_used) < cooldown:
continue
matched = False
if is_regex:
try:
if __import__("re").search(trigger, message.content, __import__("re").IGNORECASE):
matched = True
except Exception:
pass
else:
if trigger.lower() in message.content.lower():
matched = True
if matched:
reply = cfg.get("reply", "")
if reply:
try:
await message.channel.send(reply)
except Exception:
pass
async with self.config.networks() as nets:
if net_name in nets:
ar = nets[net_name].get("auto_responses", {}).get(trigger)
if ar:
ar["last_used"] = now
# ── Ephemeral delete ───────────────────────────────────────────────────
async def _schedule_ephemeral_delete(self, msg: discord.Message, delay: int) -> None:
async def _delete_after():
await asyncio.sleep(delay)
try:
await msg.delete()
except Exception:
pass
asyncio.create_task(_delete_after())
# ── Quiet hours check ──────────────────────────────────────────────────
def _is_quiet_hour(self, net_data: dict, user_id: int) -> bool:
qh = net_data.get("quiet_hours", {}).get(str(user_id))
if not qh:
return False
now = datetime.now(timezone.utc) + timedelta(hours=qh.get("tz_offset", 0))
h = now.hour
start, end = qh["start_hour"], qh["end_hour"]
if start < end:
return start <= h < end
return h >= start or h < end
# ── DM relay ───────────────────────────────────────────────────────────
async def _relay_to_dm_subs(self, net_name: str, net_data: dict, message: discord.Message) -> None:
if not net_data.get("dm_enabled"):
return
subs = net_data.get("dm_subscribers", [])
if not subs:
return
for uid in subs:
if uid == message.author.id:
continue
if self._is_quiet_hour(net_data, uid):
continue
# Check personal ignore list
ignores = net_data.get("user_ignores", {}).get(str(uid), [])
if message.author.id in ignores:
continue
user = self.bot.get_user(uid)
if not user:
continue
dm_mode = net_data.get("dm_relay_mode", "embed")
try:
if dm_mode == "embed":
em = build_dm_incoming_embed(
message.author.display_name,
str(message.author.display_avatar.url),
message.guild.name,
message.channel.name,
message.content,
net_name,
net_data.get("colour"),
)
await user.send(embed=em)
elif dm_mode == "compact":
await user.send(
content=f"**[{net_name}] {message.author.display_name}:** {truncate(message.content, 1800)}"
)
else:
await user.send(content=truncate(message.content, 1900))
except discord.Forbidden:
pass
# ── Analytics ──────────────────────────────────────────────────────────
async def _record_analytics(self, net_name: str, user_id: int) -> None:
now = datetime.now(timezone.utc)
hour_key = now.strftime("%Y-%m-%d-%H")
uid_str = str(user_id)
async with self.config.networks() as nets:
if net_name not in nets:
return
analytics = nets[net_name].setdefault("analytics", {"hourly": {}, "top_users": {}})
analytics["hourly"][hour_key] = analytics["hourly"].get(hour_key, 0) + 1
analytics["top_users"][uid_str] = analytics["top_users"].get(uid_str, 0) + 1
# Prune old hourly data (keep 7 days)
cutoff = (now - timedelta(days=7)).strftime("%Y-%m-%d-%H")
analytics["hourly"] = {
k: v for k, v in analytics["hourly"].items() if k >= cutoff
}
# ── Per-channel override helper ────────────────────────────────────────
def _get_override(self, net_data: dict, ch_id: int, key: str):
overrides = net_data.get("channel_overrides", {}).get(str(ch_id), {})
return overrides.get(key)
# ── Context menu callbacks ─────────────────────────────────────────────
async def _ctx_report_message(self, interaction: discord.Interaction, message: discord.Message) -> None:
net_name = await self._net_for_ch(message.channel.id)
if not net_name:
await interaction.response.send_message("This channel isn't in a wormhole network.", ephemeral=True)
return
nd = await self._net(net_name)
modal = ReportModal(self, net_name, message)
await interaction.response.send_modal(modal)
async def _ctx_bookmark_message(self, interaction: discord.Interaction, message: discord.Message) -> None:
uid_str = str(interaction.user.id)
bookmark = {
"content": truncate(message.content or "*[no text]*", 500),
"author": str(message.author),
"server": message.guild.name if message.guild else "DM",
"channel": message.channel.name if hasattr(message.channel, "name") else "DM",
"timestamp": datetime.now(timezone.utc).isoformat(),
"jump_url": message.jump_url,
}
async with self.config.bookmarks() as bm:
bm.setdefault(uid_str, []).append(bookmark)
if len(bm[uid_str]) > 50:
bm[uid_str] = bm[uid_str][-50:]
await interaction.response.send_message(
embed=ok_embed("Message bookmarked! Use `wh bm list` to view."),
ephemeral=True,
)
async def _ctx_delete_message(self, interaction: discord.Interaction, message: discord.Message) -> None:
net_name = await self._net_for_ch(message.channel.id)
if not net_name:
await interaction.response.send_message("Not in a network.", ephemeral=True)
return
nd = await self._net(net_name)
if not has_role(nd, interaction.user.id, Role.MODERATOR) and not await self.bot.is_owner(interaction.user):
await interaction.response.send_message("You need Moderator role or higher.", ephemeral=True)
return
# Find and delete all relayed copies
orig_id = self.msg_map.get_original(net_name, message.id) or message.id
relayed = self.msg_map.get_relayed(net_name, orig_id)
deleted = 0
for ch_id, msg_id in relayed.items():
ch = self.bot.get_channel(ch_id)
if ch:
try:
m = await ch.fetch_message(msg_id)
await m.delete()
deleted += 1
except Exception:
pass
try:
await message.delete()
deleted += 1
except Exception:
pass
await interaction.response.send_message(
embed=ok_embed(f"Deleted {deleted} message(s) across the network."),
ephemeral=True,
)
await self._audit(net_name, "ctx_delete", str(interaction.user), str(orig_id))
async def _ctx_view_profile(self, interaction: discord.Interaction, user: discord.User) -> None:
nets = await self.config.networks()
profiles_found = []
for name, nd in nets.items():
p = nd.get("user_profiles", {}).get(str(user.id))
if p:
role = get_role(nd, user.id)
profiles_found.append((name, p, role))
if not profiles_found:
await interaction.response.send_message("No wormhole profile found for this user.", ephemeral=True)
return
em = discord.Embed(
title=f"🌀 Wormhole Profile — {user.display_name}",
colour=COLOUR_INFO,
)
em.set_thumbnail(url=user.display_avatar.url)
for name, p, role in profiles_found[:10]:
em.add_field(
name=f"Network: {name}",
value=(
f"Role: **{role_name(role)}**\n"
f"Messages: {p.get('message_count', 0):,}\n"
f"First seen: {p.get('first_seen', 'Unknown')[:10]}"
),
inline=True,
)
await interaction.response.send_message(embed=em, ephemeral=True)
# ── Root command group ─────────────────────────────────────────────────
@commands.hybrid_group(name="wh", aliases=["wormhole"], invoke_without_command=True)
async def wh(self, ctx: commands.Context) -> None:
"""🌀 Wormhole — the ultimate cross-server relay."""
await ctx.send_help(ctx.command)

View file

@ -1,32 +1,15 @@
{
"author": ["everestmcarthur"],
"install_msg": "Thanks for installing **Wormhole v3.4.3** — the ultimate cross-server relay!\n\nGet started: `[p]wh create <network-name>` then `[p]wh open <network-name>` in channels you want to link.\n\nNew in v3.4.3: Fixed critical relay bug — raw string in mention_policy regex replacement caused every message to crash in _do_relay. Also added diagnostic commands (relaydebug, testsend, tracemode). v3.4.2: Fixed empty-prefix command filter. v3.4.1: Relay engine hardening.\n\nFull help: `[p]wh help` or `/wh help`",
"install_msg": "Thanks for installing **Wormhole v4.0.0** — the ultimate cross-server relay!\n\nGet started: `[p]wh create <network-name>` then `[p]wh open <network-name>` in channels you want to link.\n\n**v4.0.0 — Full Refactor:**\n• Modular architecture: models, commands, listeners, services, UI all in separate files\n• Per-network hierarchical staff system: Owner → Admin → Moderator → Helper → Member\n• Enhanced moderation: server ban/mute, allowlist, purge, mod edit/nuke, anti-raid\n• All 100+ commands work as both prefix and slash commands\n• Full relay pipeline with emoji resolution, reply jump buttons, starboard, karma\n\nFull help: `[p]wh help` or `/wh help`",
"name": "Wormhole",
"disabled": false,
"short": "The ultimate cross-server relay — hybrid commands, context menus, ToS, reports, mentions policy, 85+ commands.",
"description": "Wormhole v3.4.0 connects Discord channels across multiple servers into named networks. All commands work as both slash commands and prefix commands (hybrid). Features: real-time message relay (webhooks/embeds/compact), edit/delete/reaction/reply/sticker/pin/typing sync, bidirectional DM relay, auto-moderation engine, granular mention policy (per-network + per-server + per-user), Terms of Service acceptance gate with legal template, user report system with mod actions, context menu actions (report/bookmark/delete/profile), cross-network starboard, karma system, keyword highlights, invite codes with vanity URLs, staff announcements, portal embeds, user profiles, backup/restore, blackout scheduling, anonymous mode, network-wide polls, AFK system, auto-responses, ephemeral messages, analytics dashboard, health monitoring, message bookmarks, user colours, quiet hours, network bridging, and much more.",
"short": "The ultimate cross-server relay — modular architecture, hierarchical staff, 100+ hybrid commands.",
"description": "Wormhole v4.0.0 is a fully modular cross-server relay cog for Red-DiscordBot. Features: real-time message relay (webhooks/embeds/compact), hierarchical per-network staff system (Owner/Admin/Moderator/Helper), edit/delete/reaction/reply/sticker/pin/typing sync, bidirectional DM relay, advanced auto-moderation (spam/raid/invite/caps/zalgo/mention-spam), granular mention policy (per-network + per-server + per-user), Terms of Service acceptance gate, user report system, context menu actions, cross-network starboard, karma system, keyword highlights, invite codes with vanity URLs, announcements, portal embeds, user profiles, backup/restore, blackout scheduling, anonymous mode, network-wide polls, AFK system, auto-responses, ephemeral messages, analytics dashboard, health monitoring, message bookmarks, user colours, quiet hours, network bridging/mirroring, emoji relay for foreign servers, and much more.",
"tags": [
"relay",
"crossserver",
"wormhole",
"chat",
"bridge",
"network",
"webhook",
"moderation",
"automod",
"starboard",
"karma",
"dm",
"global",
"analytics",
"polls",
"anonymous",
"hybrid",
"slash",
"contextmenu",
"reports",
"tos"
"relay", "crossserver", "wormhole", "chat", "bridge", "network",
"webhook", "moderation", "automod", "starboard", "karma", "dm",
"global", "analytics", "polls", "anonymous", "hybrid", "slash",
"contextmenu", "reports", "tos", "hierarchy", "staff", "modular"
],
"type": "COG",
"end_user_data_statement": "This cog stores Discord user IDs, guild IDs, and channel IDs for relay functionality. Per-user data includes message counts, karma scores, keyword highlights, DM relay subscriptions, bookmarks, AFK status, ignore lists, colour preferences, quiet hour settings, analytics counters, ToS acceptance timestamps, and report records.",

View file

@ -0,0 +1 @@
"""Event listeners — the relay pipeline and sync handlers."""

View file

@ -0,0 +1,66 @@
"""Misc listeners — typing indicator sync, pin sync."""
from __future__ import annotations
import logging
import discord
from redbot.core import commands
log = logging.getLogger("red.evecogs.wormhole")
class MiscListener:
"""Mixin — typing and pin sync."""
@commands.Cog.listener()
async def on_typing(self, channel, user, when) -> None:
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 Exception:
pass
@commands.Cog.listener()
async def on_guild_channel_pins_update(self, channel, last_pin) -> None:
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:
m = await ch.fetch_message(mid)
await m.pin()
except Exception:
pass
except Exception:
pass

511
wormhole/listeners/relay.py Normal file
View file

@ -0,0 +1,511 @@
"""
on_message → relay pipeline and _do_relay core logic.
This is the heart of Wormhole — every message in a network channel flows
through on_message → gate checks → _do_relay → send to all target channels.
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Dict, Optional
import discord
from redbot.core import commands
from ..models.permissions import Role
from ..services.emoji import resolve_foreign_emojis, build_emoji_embeds_and_files
from ..ui.views import reply_jump_view
from ..utils import (
apply_mention_policy,
build_relay_embed,
check_attachment_filters,
check_automod,
check_filters,
compact_format,
info_embed,
sanitise_mentions,
truncate,
warn_embed,
)
log = logging.getLogger("red.evecogs.wormhole")
class RelayListener:
"""Mixin — on_message listener and _do_relay engine."""
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
await self._ready.wait()
if not message.guild or message.author.bot:
return
if not isinstance(message.channel, (discord.TextChannel, discord.Thread)):
return
_tracing = message.channel.id in self._trace_channels
trace: list[str] = []
if _tracing:
trace.append("✅ Guild text-channel message from non-bot")
eff_ch = message.channel.id
is_thread = isinstance(message.channel, discord.Thread)
if is_thread:
eff_ch = message.channel.parent_id
nets = await self.config.networks()
net_name = None
nd = None
for n, d in nets.items():
if eff_ch in d.get("channels", []):
net_name = n
nd = d
break
if not net_name:
if _tracing:
trace.append(f"❌ **Channel `{eff_ch}` not in any network**")
try:
await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace"))
except Exception:
pass
return
if _tracing:
trace.append(f"✅ Network: `{net_name}`")
# ── Gate checks ────────────────────────────────────────────────────
if is_thread and not nd.get("sync_threads"):
if _tracing:
trace.append("❌ **Blocked: thread sync disabled**")
try:
await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace"))
except Exception:
pass
return
if nd.get("frozen"):
if _tracing:
trace.append("❌ **Blocked: network frozen**")
try:
await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace"))
except Exception:
pass
return
# Command filtering
if message.content:
try:
ctx = await self.bot.get_context(message)
if ctx.valid:
if _tracing:
trace.append(f"❌ **Blocked: command filter** prefix=`{ctx.prefix!r}` cmd=`{ctx.command}`")
try:
await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace"))
except Exception:
pass
return
except Exception:
pass
# Mirror channel (receive-only)
if eff_ch in nd.get("mirror_channels", []):
return
# Media-only filter
if nd.get("media_only"):
if not message.attachments and not message.stickers and not (
message.embeds and any(e.type in ("image", "video", "gifv") for e in message.embeds)
):
return
# NSFW gate
if nd.get("nsfw_gate") and hasattr(message.channel, "is_nsfw") and message.channel.is_nsfw():
return
# Global blocklists
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 blocklists
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
# Rules acceptance gate
if nd.get("rules_required"):
accepted = nd.get("rules_accepted", {})
if str(message.author.id) not in accepted:
try:
prefix = await self.bot.get_prefix(message)
if isinstance(prefix, list):
prefix = prefix[0]
await message.channel.send(
embed=warn_embed(
f"{message.author.mention}, you must accept the network rules before messaging.\n"
f"Use `{prefix}wh accept {net_name}` to view and accept."
),
delete_after=15,
)
except Exception:
pass
return
# Content filters
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 Exception:
pass
return
# Attachment filters
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 Exception:
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 Exception:
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("🚨 Raid detected! Network auto-frozen."))
await self._status(
net_name, nd, None,
"🚨 **Raid detected — network auto-frozen!** Staff: use `wh set unfreeze` 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 Exception:
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 Exception:
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 Exception:
pass
return
self.slowmode_tracker.setdefault(net_name, {})[message.author.id] = time.monotonic()
# Relay delay
delay = nd.get("relay_delay", 0)
if delay > 0:
await asyncio.sleep(min(delay, 30))
# AFK system
try:
await self._check_afk(net_name, nd, message)
except Exception as exc:
log.debug("AFK check error (relay continues): %s", exc)
# Auto-responses
try:
await self._check_auto_responses(net_name, nd, message)
except Exception as exc:
log.debug("Auto-response error (relay continues): %s", exc)
# ── Relay ──────────────────────────────────────────────────────────
if _tracing:
trace.append("✅ All gate checks passed — calling _do_relay")
try:
await self._do_relay(net_name, nd, nets, message, eff_ch)
if _tracing:
trace.append("✅ **_do_relay completed successfully**")
try:
await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace — RELAYED"))
except Exception:
pass
except Exception as exc:
log.error("Relay engine error net=%s ch=%s: %s", net_name, message.channel.id, exc, exc_info=True)
if _tracing:
trace.append(f"❌ **_do_relay EXCEPTION: `{exc}`**")
try:
await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace — ERROR"))
except Exception:
pass
async def _do_relay(
self, net_name: str, nd: dict, nets: dict, message: discord.Message, eff_ch: int
) -> None:
"""Core relay logic — build payload, send to all target channels."""
relay_mode = nd.get("relay_mode", "webhook")
nick = nd.get("server_nicknames", {}).get(str(message.guild.id))
# ── Mention policy ─────────────────────────────────────────────────
mp = nd.get("mention_policy", {})
server_overrides = nd.get("server_mention_overrides", {}).get(str(message.guild.id))
active_policy = server_overrides if server_overrides else mp
exempt = nd.get("mention_exempt_users", [])
optouts = set(nd.get("mention_optout_users", []))
if active_policy:
content = apply_mention_policy(
message.content or "", active_policy, message.author.id, exempt, optouts
)
else:
mc = nd.get("mention_control", {})
content = sanitise_mentions(message.content or "", mc)
# ── Identity ───────────────────────────────────────────────────────
is_anon = nd.get("anonymous", False)
if is_anon:
anon_name = self._anon_name(nd, message.author.id)
avatar = self._anon_avatar(message.author.id)
uname = anon_name
else:
avatar = self._avatar(message, nd.get("image_mode", "user"), nd.get("custom_icon"))
uname = self._name(message, nd.get("name_mode", "both"), nd.get("custom_name"), nick)
user_colour = nd.get("user_colours", {}).get(str(message.author.id))
# ── Reply context ──────────────────────────────────────────────────
reply_jump_urls: Dict[int, str] = {}
reply_fallback_url: Optional[str] = None
if nd.get("sync_replies") and message.reference and message.reference.message_id:
try:
ref = message.reference.cached_message or await message.channel.fetch_message(
message.reference.message_id
)
ref_name = self._anon_name(nd, ref.author.id) if is_anon else ref.author.display_name
preview = truncate(ref.content, 100) if ref.content else "*[attachment]*"
content = f"> **↩ {ref_name}:** {preview}\n{content}"
if ref.guild:
reply_fallback_url = (
f"https://discord.com/channels/{ref.guild.id}/{ref.channel.id}/{ref.id}"
)
orig_ref_id = self.msg_map.get_original(net_name, ref.id)
ref_relayed = (
self.msg_map.forward.get(net_name, {}).get(orig_ref_id, {})
if orig_ref_id
else self.msg_map.forward.get(net_name, {}).get(ref.id, {})
)
for cid, mid in ref_relayed.items():
target_ch = self.bot.get_channel(cid)
if target_ch and target_ch.guild:
reply_jump_urls[cid] = (
f"https://discord.com/channels/{target_ch.guild.id}/{cid}/{mid}"
)
if orig_ref_id:
relayed_ch_ids = set(ref_relayed.keys())
for net_ch_id in nd.get("channels", []):
if net_ch_id not in relayed_ch_ids and net_ch_id not in reply_jump_urls:
net_ch = self.bot.get_channel(net_ch_id)
if net_ch and net_ch.guild:
reply_jump_urls[net_ch_id] = (
f"https://discord.com/channels/{net_ch.guild.id}/{net_ch_id}/{orig_ref_id}"
)
reply_jump_urls.setdefault(ref.channel.id, reply_fallback_url)
except Exception:
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)
# ── Forward embeds ─────────────────────────────────────────────────
extra_embeds: list[discord.Embed] = []
if nd.get("forward_embeds") and message.embeds:
extra_embeds = [e for e in message.embeds if e.type == "rich"]
# ── Resolve foreign emojis ─────────────────────────────────────────
emoji_img_data: list[tuple[str, bytes]] = []
if content:
content, emoji_img_data = await resolve_foreign_emojis(self.bot, content)
# ── Build target list ──────────────────────────────────────────────
relay_targets = [cid for cid in nd["channels"] if cid != eff_ch]
for bridge_net in nd.get("bridge_to", []):
bd = nets.get(bridge_net)
if bd and not bd.get("frozen"):
relay_targets.extend(bd.get("channels", []))
# ── Send to each target channel ────────────────────────────────────
mapping: Dict[int, int] = {}
for ch_id in relay_targets:
if ch_id == eff_ch:
continue
ch = self.bot.get_channel(ch_id)
if not ch:
continue
ch_mode = self._get_override(nd, ch_id, "relay_mode") or relay_mode
# Reply jump button
jump_url = reply_jump_urls.get(ch_id) or reply_fallback_url
_reply_view = reply_jump_view(jump_url) if jump_url else None
_view_kw = {"view": _reply_view} if _reply_view else {}
# Fresh emoji embeds/files (File objects are single-use)
emoji_embeds, emoji_files = build_emoji_embeds_and_files(emoji_img_data)
try:
sent_msg = None
if ch_mode == "webhook":
sent_msg = await self._relay_webhook(
ch, message, content, uname, avatar, extra_embeds,
emoji_embeds, emoji_files, emoji_img_data, _view_kw, nick, nd,
)
elif ch_mode == "embed":
ee, ef = build_emoji_embeds_and_files(emoji_img_data)
em = build_relay_embed(message, nick, user_colour or nd.get("colour"))
all_embeds = [em] + (extra_embeds or []) + ee
sent_msg = await ch.send(embeds=all_embeds[:10], files=ef or None, **_view_kw)
elif ch_mode == "compact":
g = nick or message.guild.name
display = anon_name if is_anon else message.author.display_name
files = []
for a in message.attachments:
try:
files.append(await a.to_file())
except Exception:
pass
ce, cf = build_emoji_embeds_and_files(emoji_img_data)
files.extend(cf)
sent_msg = await ch.send(
content=compact_format(g, display, content),
files=files or None,
embeds=ce[:10] or None,
**_view_kw,
)
if sent_msg:
mapping[ch_id] = sent_msg.id
if nd.get("ephemeral_delay", 0) > 0:
await self._schedule_ephemeral_delete(sent_msg, nd["ephemeral_delay"])
except Exception as exc:
log.error("Relay fail ch=%s net=%s: %s", ch_id, net_name, exc, exc_info=True)
if mapping:
self.msg_map.add(net_name, message.id, mapping)
# Stats + profile + analytics
async with self.config.networks() as ns:
if net_name in ns:
ns[net_name]["total_messages"] = ns[net_name].get("total_messages", 0) + 1
await self._update_profile(net_name, message.author, message.guild.id)
await self._record_analytics(net_name, message.author.id)
# DM relay + highlights
await self._relay_to_dm_subs(net_name, nd, message)
await self._check_highlights(net_name, nd, message.content, message.author.id)
async def _relay_webhook(
self, ch, message, content, uname, avatar, extra_embeds,
emoji_embeds, emoji_files, emoji_img_data, view_kw, nick, nd,
) -> Optional[discord.Message]:
"""Send via webhook with automatic retry/fallback."""
files = []
for a in message.attachments:
try:
files.append(await a.to_file())
except Exception:
pass
files.extend(emoji_files)
all_embeds = (extra_embeds or []) + emoji_embeds
send_content = content if content else None
if not send_content and not files and not all_embeds:
send_content = "*[empty message]*"
try:
wh = await self._wh(ch)
return await wh.send(
content=send_content,
username=truncate(uname, 80),
avatar_url=avatar,
files=files or discord.utils.MISSING,
embeds=all_embeds[:10] or discord.utils.MISSING,
wait=True,
**view_kw,
)
except (discord.NotFound, discord.InvalidData):
# Webhook stale — retry with fresh one
try:
wh = await self._wh(ch, force_refresh=True)
files2 = []
for a in message.attachments:
try:
files2.append(await a.to_file())
except Exception:
pass
ee2, ef2 = build_emoji_embeds_and_files(emoji_img_data)
files2.extend(ef2)
all_embeds2 = (extra_embeds or []) + ee2
send_content2 = content if content else None
if not send_content2 and not files2 and not all_embeds2:
send_content2 = "*[empty message]*"
return await wh.send(
content=send_content2,
username=truncate(uname, 80),
avatar_url=avatar,
files=files2 or discord.utils.MISSING,
embeds=all_embeds2[:10] or discord.utils.MISSING,
wait=True,
**view_kw,
)
except Exception:
log.warning("Webhook retry failed for %s, falling back to embed", ch.id)
em = build_relay_embed(message, nick, nd.get("colour"))
return await ch.send(embeds=[em] + (extra_embeds or [])[:9], **view_kw)
except discord.Forbidden:
log.warning("No webhook perms in %s, falling back to embed", ch.id)
em = build_relay_embed(message, nick, nd.get("colour"))
return await ch.send(embeds=[em] + (extra_embeds or [])[:9], **view_kw)

227
wormhole/listeners/sync.py Normal file
View file

@ -0,0 +1,227 @@
"""
Sync listeners — edit, delete, reaction add/remove, starboard, karma.
"""
from __future__ import annotations
import logging
import discord
from redbot.core import commands
from ..utils import build_star_embed, truncate, warn_embed
log = logging.getLogger("red.evecogs.wormhole")
class SyncListener:
"""Mixin — edit/delete/reaction sync listeners."""
# ── Edit sync ──────────────────────────────────────────────────────────
@commands.Cog.listener()
async def on_message_edit(self, before: discord.Message, after: discord.Message) -> None:
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 Exception:
pass
# ── Delete sync ────────────────────────────────────────────────────────
@commands.Cog.listener()
async def on_message_delete(self, message: discord.Message) -> None:
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 Exception:
pass
# ── Reaction add — sync + karma + starboard ───────────────────────────
@commands.Cog.listener()
async def on_reaction_add(self, reaction: discord.Reaction, user: discord.User) -> None:
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 Exception:
pass
# Karma
if nd.get("karma_enabled") and str(reaction.emoji) == nd.get("karma_emoji", "👍"):
target_msg = msg
if target_msg.author.id != user.id:
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
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:
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 Exception:
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 Exception:
pass
# ── Reaction remove sync ───────────────────────────────────────────────
@commands.Cog.listener()
async def on_reaction_remove(self, reaction: discord.Reaction, user: discord.User) -> None:
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 Exception:
pass

View file

@ -0,0 +1,16 @@
"""Data models for Wormhole — config defaults, permissions, message mapping."""
from .config import DEFAULT_NETWORK, DEFAULT_GLOBAL, MAP_LIMIT
from .message_map import MessageMap
from .permissions import Role, has_role, requires_role, role_name
__all__ = [
"DEFAULT_NETWORK",
"DEFAULT_GLOBAL",
"MAP_LIMIT",
"MessageMap",
"Role",
"has_role",
"requires_role",
"role_name",
]

235
wormhole/models/config.py Normal file
View file

@ -0,0 +1,235 @@
"""
Default configuration structures for Wormhole networks.
All per-network data lives inside ``networks.<name>`` in Red's Config.
Global data (cross-network) lives at the top level.
"""
from __future__ import annotations
DEFAULT_NETWORK: dict = {
# ── Ownership / staff ──────────────────────────────────────────────────
"owner_id": 0,
"staff": {}, # {user_id_str: Role int} (new hierarchy)
"staff_ids": [], # LEGACY — flat list, migrated on read
# ── 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": [],
# ── Feature toggles ────────────────────────────────────────────────────
"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,
# ── Auto-moderation ────────────────────────────────────────────────────
"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 ────────────────────────────────────────────────────────────
"invites": {},
"vanity_invite": None,
# ── Portal / welcome ───────────────────────────────────────────────────
"portal_messages": {},
"welcome_message": "",
# ── Mention control (legacy) ───────────────────────────────────────────
"mention_control": {
"strip_everyone": True,
"strip_role_mentions": False,
"strip_user_mentions": False,
},
# ── User profiles ──────────────────────────────────────────────────────
"user_profiles": {},
"blackout_schedules": [],
# ── DM relay ───────────────────────────────────────────────────────────
"dm_enabled": False,
"dm_subscribers": [],
"dm_relay_mode": "embed", # embed | compact | plain
# ── Starboard ──────────────────────────────────────────────────────────
"starboard_enabled": False,
"starboard_channel": None,
"starboard_threshold": 3,
"starred_messages": {},
# ── Audit log ──────────────────────────────────────────────────────────
"audit_log": [],
# ── Attachment filters ─────────────────────────────────────────────────
"blocked_extensions": [],
"max_filesize": None,
# ── Karma ──────────────────────────────────────────────────────────────
"karma_enabled": False,
"karma_emoji": "👍",
"karma_scores": {},
# ── MOTD / Rules ───────────────────────────────────────────────────────
"motd": "",
"rules": "",
# ── Relay delay ────────────────────────────────────────────────────────
"relay_delay": 0,
# ── Highlights ─────────────────────────────────────────────────────────
"highlights": {},
# ── Network roles (legacy, unused) ─────────────────────────────────────
"roles": {},
# ── Per-channel overrides ──────────────────────────────────────────────
"channel_overrides": {},
# ── Scheduled messages ─────────────────────────────────────────────────
"scheduled_messages": [],
# ── Slowmode ───────────────────────────────────────────────────────────
"slowmode": 0,
# ── Discovery ──────────────────────────────────────────────────────────
"public": False,
"tags": [],
# ── Typing indicator relay ─────────────────────────────────────────────
"sync_typing": False,
# ── Anonymous mode ─────────────────────────────────────────────────────
"anonymous": False,
"anon_salt": "",
# ── Mirror channels ────────────────────────────────────────────────────
"mirror_channels": [],
# ── Ephemeral messages ─────────────────────────────────────────────────
"ephemeral_delay": 0,
# ── Auto-responses ─────────────────────────────────────────────────────
"auto_responses": {},
# ── Media-only mode ────────────────────────────────────────────────────
"media_only": False,
# ── Analytics ──────────────────────────────────────────────────────────
"analytics": {
"hourly": {},
"top_users": {},
},
# ── Health ─────────────────────────────────────────────────────────────
"last_health_check": None,
"unhealthy_channels": [],
# ── Polls ──────────────────────────────────────────────────────────────
"active_polls": {},
# ── AFK system ─────────────────────────────────────────────────────────
"afk_users": {},
# ── Personal ignore list ───────────────────────────────────────────────
"user_ignores": {},
# ── User colours ───────────────────────────────────────────────────────
"user_colours": {},
# ── Quiet hours ────────────────────────────────────────────────────────
"quiet_hours": {},
# ── Network bridging ───────────────────────────────────────────────────
"bridge_from": [],
"bridge_to": [],
# ── Granular mention policy ────────────────────────────────────────────
"mention_policy": {
"allow_user_mentions": False,
"allow_role_mentions": False,
"allow_everyone": False,
"allow_here": False,
},
"server_mention_overrides": {},
"mention_exempt_users": [],
"mention_optout_users": [],
# ── ToS acceptance gate ────────────────────────────────────────────────
"rules_required": False,
"rules_text": "",
"rules_accepted": {},
# ── Report system ──────────────────────────────────────────────────────
"reports": [],
"report_counter": 0,
}
DEFAULT_GLOBAL: dict = {
"networks": {},
"max_networks_per_user": 10,
"global_banned_users": [],
"global_banned_servers": [],
"bookmarks": {},
}
MAP_LIMIT: int = 2_000

View file

@ -0,0 +1,54 @@
"""
In-memory bidirectional message map for tracking relayed messages.
Maps original message IDs ↔ relayed copies across channels within a network.
Capped at MAP_LIMIT entries per direction to bound memory usage.
"""
from __future__ import annotations
from collections import OrderedDict
from typing import Dict, List, Optional
from .config import MAP_LIMIT
class MessageMap:
"""Bidirectional map: original_id ↔ {channel_id: relayed_msg_id}.
``forward[net][original_id] = {ch_id: relayed_id, ...}``
``reverse[net][relayed_id] = original_id``
"""
def __init__(self) -> None:
self.forward: Dict[str, OrderedDict[int, Dict[int, int]]] = {}
self.reverse: Dict[str, OrderedDict[int, int]] = {}
def add(self, network: str, original_id: int, mapping: Dict[int, int]) -> None:
"""Record that *original_id* was relayed to *mapping* channels."""
fwd = self.forward.setdefault(network, OrderedDict())
rev = self.reverse.setdefault(network, OrderedDict())
fwd[original_id] = mapping
for relayed_id in mapping.values():
rev[relayed_id] = original_id
# Prune oldest entries
while len(fwd) > MAP_LIMIT:
_old_orig, old_map = fwd.popitem(last=False)
for rid in old_map.values():
rev.pop(rid, None)
while len(rev) > MAP_LIMIT * 2:
rev.popitem(last=False)
def get_relayed(self, network: str, original_id: int) -> Dict[int, int]:
"""Get ``{ch_id: relayed_id}`` for an original message."""
return self.forward.get(network, {}).get(original_id, {})
def get_original(self, network: str, relayed_id: int) -> Optional[int]:
"""Get the original message ID from a relayed copy ID."""
return self.reverse.get(network, {}).get(relayed_id)
def get_all_relayed_ids(self, network: str, original_id: int) -> List[int]:
"""Get all relayed message IDs for an original."""
return list(self.get_relayed(network, original_id).values())

View file

@ -0,0 +1,162 @@
"""
Per-network hierarchical permission system for Wormhole.
Roles are scoped to individual networks — a user can be Admin in one
network and have no role in another. Bot owners (Red's built-in
``bot.is_owner()``) bypass all checks globally.
Hierarchy (highest → lowest):
Owner → Admin → Moderator → Helper → Member
Higher roles can assign any role below them.
"""
from __future__ import annotations
import enum
import functools
from typing import Optional
import discord
from redbot.core import commands
class Role(enum.IntEnum):
"""Network permission tiers. Higher value = more power."""
MEMBER = 0
HELPER = 1
MODERATOR = 2
ADMIN = 3
OWNER = 4
_ROLE_NAMES = {
Role.OWNER: "Owner",
Role.ADMIN: "Admin",
Role.MODERATOR: "Moderator",
Role.HELPER: "Helper",
Role.MEMBER: "Member",
}
_ROLE_LOOKUP = {v.lower(): k for k, v in _ROLE_NAMES.items()}
def role_name(role: Role) -> str:
"""Human-friendly name for a role."""
return _ROLE_NAMES.get(role, "Unknown")
def role_from_str(name: str) -> Optional[Role]:
"""Parse a role name (case-insensitive). Returns ``None`` on failure."""
return _ROLE_LOOKUP.get(name.strip().lower())
def _get_user_role(net_data: dict, user_id: int) -> Role:
"""Determine a user's role in a network from its config dict."""
if user_id == net_data.get("owner_id"):
return Role.OWNER
staff = net_data.get("staff", {})
uid_str = str(user_id)
if uid_str in staff:
return Role(staff[uid_str])
# Legacy flat list migration: treat old staff_ids entries as Admin
if user_id in net_data.get("staff_ids", []):
return Role.ADMIN
return Role.MEMBER
def has_role(net_data: dict, user_id: int, minimum: Role) -> bool:
"""Check whether *user_id* meets *minimum* role in this network."""
return _get_user_role(net_data, user_id) >= minimum
def get_role(net_data: dict, user_id: int) -> Role:
"""Return the user's role in a network."""
return _get_user_role(net_data, user_id)
def can_assign(actor_role: Role, target_role: Role) -> bool:
"""Whether *actor_role* is allowed to assign/revoke *target_role*."""
return actor_role > target_role
def set_staff_role(net_data: dict, user_id: int, role: Role) -> None:
"""Assign *role* to *user_id* in the network config (mutates in place).
Setting ``Role.MEMBER`` removes the user from the staff dict.
"""
staff = net_data.setdefault("staff", {})
uid_str = str(user_id)
if role <= Role.MEMBER:
staff.pop(uid_str, None)
else:
staff[uid_str] = int(role)
def remove_staff(net_data: dict, user_id: int) -> None:
"""Remove a user from network staff entirely."""
set_staff_role(net_data, user_id, Role.MEMBER)
def list_staff(net_data: dict) -> dict[int, Role]:
"""Return ``{user_id: Role}`` for all staff (including owner)."""
result: dict[int, Role] = {}
owner = net_data.get("owner_id")
if owner:
result[owner] = Role.OWNER
for uid_str, level in net_data.get("staff", {}).items():
try:
result[int(uid_str)] = Role(level)
except (ValueError, KeyError):
pass
# Legacy migration
for uid in net_data.get("staff_ids", []):
if uid not in result:
result[uid] = Role.ADMIN
return result
# ── Decorator for commands ─────────────────────────────────────────────────
def requires_role(minimum: Role):
"""Decorator factory — the command must supply *name* as first arg.
Usage inside a cog::
@commands.command()
@requires_role(Role.MODERATOR)
async def wh_mod_ban(self, ctx, name: str, user: discord.User):
...
The decorator fetches the network data for *name*, checks the
invoker's role, and short-circuits with an error embed if they
lack permission. Bot owners always pass.
"""
def decorator(func):
@functools.wraps(func)
async def wrapper(self, ctx: commands.Context, name: str, *args, **kwargs):
# Bot owner bypasses everything
if await self.bot.is_owner(ctx.author):
return await func(self, ctx, name, *args, **kwargs)
# Fetch network
nd = await self._net(name)
if nd is None:
from ..utils import err_embed
await ctx.send(embed=err_embed(f"Network `{name}` not found."))
return
if not has_role(nd, ctx.author.id, minimum):
from ..utils import err_embed
await ctx.send(
embed=err_embed(
f"You need at least **{role_name(minimum)}** "
f"role in `{name}` to use this command."
)
)
return
return await func(self, ctx, name, *args, **kwargs)
return wrapper
return decorator

View file

@ -0,0 +1 @@
"""Background services — automod, emoji relay, analytics, task loops."""

View file

@ -0,0 +1,78 @@
"""
Foreign emoji relay — resolve custom emojis the bot can't see.
Unknown emojis are replaced with ``:name:`` in text and their images
are downloaded from the CDN, then shown as embed thumbnails (~80×80 px)
via the ``attachment://`` scheme.
"""
from __future__ import annotations
import io
import re
from typing import TYPE_CHECKING
import discord
if TYPE_CHECKING:
from redbot.core.bot import Red
_CUSTOM_EMOJI_RE = re.compile(r"<(a?):(\w+):(\d+)>")
_EMOJI_IMAGE_LIMIT = 5 # max emoji thumbnails per message
async def resolve_foreign_emojis(
bot: Red, content: str
) -> tuple[str, list[tuple[str, bytes]]]:
"""Replace unknown custom emojis with ``:name:`` and fetch small images.
Returns ``(new_content, [(filename, image_bytes), ...])``.
"""
unknown: list[tuple[str, int, str]] = []
def _check(m: re.Match) -> str:
animated, name, eid = m.group(1), m.group(2), int(m.group(3))
if bot.get_emoji(eid):
return m.group(0)
ext = "gif" if animated else "png"
unknown.append((name, eid, ext))
return f":{name}:"
new_content = _CUSTOM_EMOJI_RE.sub(_check, content)
emoji_data: list[tuple[str, bytes]] = []
if unknown:
import aiohttp
async with aiohttp.ClientSession() as session:
for name, eid, ext in unknown[:_EMOJI_IMAGE_LIMIT]:
try:
url = f"https://cdn.discordapp.com/emojis/{eid}.{ext}?size=48"
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=5)
) as resp:
if resp.status == 200:
emoji_data.append((f"{name}.{ext}", await resp.read()))
except Exception:
pass
return new_content, emoji_data
def build_emoji_embeds_and_files(
emoji_data: list[tuple[str, bytes]],
) -> tuple[list[discord.Embed], list[discord.File]]:
"""Build embed-thumbnail pairs for foreign emoji images.
Each unknown emoji gets a tiny embed whose thumbnail references the
attached file via ``attachment://``. Thumbnails render at ~80×80 px.
"""
embeds: list[discord.Embed] = []
files: list[discord.File] = []
for fname, fdata in emoji_data:
f = discord.File(io.BytesIO(fdata), filename=fname)
em = discord.Embed()
em.set_thumbnail(url=f"attachment://{fname}")
files.append(f)
embeds.append(em)
return embeds, files

1
wormhole/ui/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Discord UI components — modals, views, context menus."""

75
wormhole/ui/modals.py Normal file
View file

@ -0,0 +1,75 @@
"""Discord UI modals for Wormhole (e.g. report form)."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING
import discord
from ..utils import ok_embed, warn_embed
if TYPE_CHECKING:
from ..core import Wormhole
class ReportModal(discord.ui.Modal, title="Report Message"):
reason = discord.ui.TextInput(
label="Reason",
style=discord.TextStyle.paragraph,
placeholder="Describe why you're reporting this message...",
required=True,
max_length=1000,
)
def __init__(self, cog: Wormhole, net_name: str, message: discord.Message) -> None:
super().__init__()
self.cog = cog
self.net_name = net_name
self.message = message
async def on_submit(self, interaction: discord.Interaction) -> None:
nets = await self.cog.config.networks()
nd = nets.get(self.net_name)
if not nd:
await interaction.response.send_message("Network not found.", ephemeral=True)
return
report_id = nd.get("report_counter", 0) + 1
report = {
"id": report_id,
"reporter_id": interaction.user.id,
"author_id": self.message.author.id,
"channel_id": self.message.channel.id,
"guild_id": self.message.guild.id if self.message.guild else None,
"message_content_hash": str(hash(self.message.content or "")),
"reason": self.reason.value,
"timestamp": datetime.now(timezone.utc).isoformat(),
"resolved": False,
"resolved_by": None,
}
async with self.cog.config.networks() as ns:
if self.net_name in ns:
ns[self.net_name].setdefault("reports", []).append(report)
ns[self.net_name]["report_counter"] = report_id
owner = self.cog.bot.get_user(nd.get("owner_id"))
if owner:
try:
await owner.send(
embed=warn_embed(
f"**Report #{report_id}** in `{self.net_name}`\n"
f"Reporter: {interaction.user} (`{interaction.user.id}`)\n"
f"Author: {self.message.author} (`{self.message.author.id}`)\n"
f"Reason: {self.reason.value}",
title="🚩 New Report",
)
)
except discord.Forbidden:
pass
await interaction.response.send_message(
embed=ok_embed(f"Report #{report_id} submitted. Staff will review it."),
ephemeral=True,
)

16
wormhole/ui/views.py Normal file
View file

@ -0,0 +1,16 @@
"""Persistent and ephemeral views for Wormhole messages."""
from __future__ import annotations
import discord
def reply_jump_view(url: str) -> discord.ui.View:
"""Create a View with a single URL button pointing to the replied message."""
view = discord.ui.View()
view.add_item(discord.ui.Button(
style=discord.ButtonStyle.link,
label="Jump to replied message",
url=url,
))
return view