v3.4.0 — Phase 5: hybrid commands, context menus, mention policy, ToS gate, reports, mod edit/delete

Phase 5 — Security, Privacy & Power Update:

HYBRID COMMANDS
- Main command group converted to hybrid_group (slash + prefix)
- All commands work as both /wh and [p]wh

CONTEXT MENU ACTIONS (right-click)
- Report to Wormhole — opens modal for report reason
- Wormhole Bookmark — saves message to DM
- Wormhole Delete — staff can delete from entire network
- Wormhole Profile — view user stats across networks

GRANULAR MENTION POLICY
- Per-network control: allow/block @user, @role, @everyone, @here
- Per-server overrides (different rules per guild)
- Per-user exemptions (trusted users bypass policy)
- Replaces legacy strip-* with proper policy engine
- wh mentions set/server-set/exempt/unexempt/status

TERMS OF SERVICE / RULES ACCEPTANCE GATE
- wh tos enable — require acceptance before messaging
- Built-in legal template (6 sections, prosecution-ready)
- Consent tracking with timestamps
- wh tos set (custom text), template (reset), accepted (view), reset (force re-accept)
- wh accept / wh agree — user-facing acceptance flow
- Blocks relay for non-accepted users with helpful reminder

MOD EDIT/DELETE ACROSS NETWORK
- wh mod edit <name> <msg_id> <new_content> — edits all relayed copies
- wh mod nuke <name> <msg_id> — deletes from every server + original
- Both audit-logged

REPORT SYSTEM
- wh report message — report via reply or message ID
- Context menu report with modal (reason input)
- Anti-abuse cooldown (60s between reports)
- Content hashing for audit trails
- Reports sent to log channel + network owner DM
- wh report list/resolve/action (ban/mute/warn/dismiss)
- Capped at 200 reports per network

PRIVACY & SECURITY
- _ReportModal class for context menu reporting
- Content hash stored instead of plaintext in report records
- Report cooldowns prevent abuse
- ToS acceptance with ISO timestamps for legal compliance

4,196 lines in wormhole.py, 419 in utils.py — 85+ commands total
This commit is contained in:
everestmcarthur 2026-05-08 02:52:36 +00:00
parent ccd5079ead
commit f8fcb1670b
5 changed files with 931 additions and 104 deletions

152
README.md
View file

@ -12,11 +12,20 @@ A premium cog repository for [Red-DiscordBot](https://github.com/Cog-Creators/Re
---
# Wormhole v3.3.0
# Wormhole v3.4.0
**The ultimate cross-server message relay for Red-DiscordBot.**
Connect Discord channels across unlimited servers into named networks. Messages, edits, deletes, reactions, replies, stickers, pins, typing indicators, and files are all relayed in real-time.
Connect Discord channels across unlimited servers into named networks. All commands work as **both slash commands and prefix commands** (hybrid). Messages, edits, deletes, reactions, replies, stickers, pins, typing indicators, and files are all relayed in real-time.
## What's New in v3.4.0
- **Hybrid Commands** — every command works as both `/wh` and `[p]wh`
- **Context Menu Actions** — right-click messages/users for Report, Bookmark, Delete, Profile
- **Granular Mention Policy** — control @user, @role, @everyone, @here per-network AND per-server, with per-user exemptions
- **Terms of Service Gate** — require users to accept legal ToS before messaging, with built-in prosecution-ready legal template
- **Mod Edit/Delete Across Network** — staff can edit or delete a message from every server at once
- **Report System** — users can report messages (prefix command or right-click), staff review/action/resolve, with DM warnings and auto-ban/mute
## Feature Highlights
@ -24,15 +33,19 @@ Connect Discord channels across unlimited servers into named networks. Messages,
|----------|----------|
| **Relay** | Webhook / embed / compact modes, edit & delete sync, reply threading, sticker forwarding, pin sync, typing indicators, file relay |
| **DM Relay** | Bidirectional DM ↔ network messaging, quiet hours, personal ignore lists |
| **Moderation** | Ban/mute users & servers, word + regex filters, attachment filters, global blocklist, purge |
| **Moderation** | Ban/mute users & servers, word + regex filters, attachment filters, global blocklist, purge, network-wide edit & delete |
| **Auto-Mod** | Anti-spam, anti-raid, anti-caps, anti-invite, anti-link, anti-zalgo, anti-spoiler, anti-emote-spam, anti-newlines |
| **Mentions** | Per-network + per-server policy for @user, @role, @everyone, @here with exemptions |
| **ToS/Rules** | Acceptance gate, legal template, consent tracking with timestamps, force re-accept |
| **Reports** | Report via command or context menu, staff review, resolve, action (ban/mute/warn/dismiss) |
| **Staff** | Staff system, ownership transfer, audit log, announcements |
| **Invites** | Invite codes, vanity URLs, expiry, max uses, allowlists |
| **Social** | Karma/reputation, starboard, user profiles, keyword highlights |
| **Scheduling** | Scheduled messages, blackout windows, relay delay, slowmode |
| **Phase 4** | Anonymous mode, mirror channels, polls, AFK, auto-responses, ephemeral messages, media-only, analytics, health monitor, bookmarks, user colours, quiet hours, network bridging |
| **Context Menus** | Report to Wormhole, Wormhole Bookmark, Wormhole Delete (staff), Wormhole Profile |
## Command Reference
## Command Reference (85+ commands)
### Core
| Command | Description |
@ -48,106 +61,79 @@ Connect Discord channels across unlimited servers into named networks. Messages,
### Settings (`[p]wh set`)
| Command | Description |
|---------|-------------|
| `set mode <name> <webhook\|embed\|compact>` | Relay mode |
| `set colour <name> <hex>` | Embed colour |
| `set icon <name> <url>` | Custom network icon |
| `set freeze <name> <bool>` | Freeze/unfreeze |
| `set nsfw <name> <bool>` | NSFW gate |
| `set ratelimit <name> <msgs> <secs>` | Rate limit |
| `set slowmode <name> <secs>` | Per-user slowmode |
| `set welcome <name> <msg>` | Welcome message |
| `set motd <name> <msg>` | Message of the day |
| `set rules <name> <text>` | Network rules |
| `set mention <name> <key> <bool>` | Mention control |
| `set log-channel <name> <ch>` | Log channel |
| `set max-filesize <name> <bytes>` | Max file size |
| `set delay <name> <secs>` | Relay delay |
| `set anonymous <name> <bool>` | Anonymous mode |
| `set ephemeral <name> <secs>` | Auto-delete timer |
| `set media-only <name> <bool>` | Media-only mode |
| `set mode` | Relay mode (webhook/embed/compact) |
| `set colour/icon/description` | Network identity |
| `set freeze/silent/nsfw-gate` | Toggles |
| `set ratelimit/slowmode/relay-delay` | Flow control |
| `set anonymous/ephemeral/media-only` | Special modes |
| `set sync-*` | Toggle edit/delete/reaction/reply/sticker/thread/pin/typing sync |
| `set strip-everyone/roles/users` | Legacy mention stripping |
| `set channel-override` | Per-channel setting overrides |
### Sync Toggles
### Mention Policy (`[p]wh mentions`) — NEW
| Command | Description |
|---------|-------------|
| `set edits / deletes / replies / reactions / stickers / threads / typing / pins` | Toggle sync features |
| `mentions set <name> <type> <bool>` | Network-wide policy (users/roles/everyone/here) |
| `mentions server-set <name> <type> <bool>` | Per-server override |
| `mentions exempt <name> @user` | Allow user to bypass policy |
| `mentions unexempt <name> @user` | Remove exemption |
| `mentions status <name>` | View current policy |
### Staff & Moderation
### Terms of Service (`[p]wh tos`) — NEW
| Command | Description |
|---------|-------------|
| `staff add/remove/list` | Manage staff |
| `transfer <name> <user>` | Transfer ownership |
| `mod ban/unban/mute/unmute <name> <user>` | User moderation |
| `tos enable <name>` | Require acceptance before messaging |
| `tos disable <name>` | Remove requirement |
| `tos set <name> <text>` | Custom ToS text |
| `tos template <name>` | Reset to legal template |
| `tos accepted <name>` | View who accepted |
| `tos reset <name>` | Force everyone to re-accept |
| `[p]wh accept <name>` | View the ToS |
| `[p]wh agree <name>` | Accept the ToS |
### Reports (`[p]wh report`) — NEW
| Command | Description |
|---------|-------------|
| `report message [msg_id] [reason]` | Report a message (or reply to one) |
| `report list <name> [show_resolved]` | View reports (staff) |
| `report resolve <name> <id>` | Mark as resolved |
| `report action <name> <id> <action>` | Take action: ban, mute, warn, dismiss |
### Mod Tools (`[p]wh mod`)
| Command | Description |
|---------|-------------|
| `mod ban/unban/mute/unmute` | User moderation |
| `mod ban-server/unban-server/mute-server/unmute-server` | Server moderation |
| `mod allowlist-add/allowlist-remove` | Server allowlist |
| `mod edit <name> <msg_id> <new_text>` | Edit across entire network — NEW |
| `mod nuke <name> <msg_id>` | Delete from entire network — NEW |
| `mod purge <name> [count]` | Purge relayed messages |
### Filters
| Command | Description |
|---------|-------------|
| `filter add-word/remove-word` | Word filters |
| `filter add-regex/remove-regex` | Regex filters |
| `filter list` | List all filters |
### Context Menus (right-click) — NEW
| Action | Description |
|--------|-------------|
| **Report to Wormhole** | Right-click any message → opens report modal |
| **Wormhole Bookmark** | Right-click any message → saves to DM |
| **Wormhole Delete** | Right-click any message → deletes from all servers (staff) |
| **Wormhole Profile** | Right-click any user → view their network stats |
### Auto-Moderation
| Command | Description |
|---------|-------------|
| `automod enable/disable` | Toggle auto-mod |
| `automod anti-spam/anti-caps/anti-invite/anti-link` | Toggle protections |
| `automod anti-zalgo/anti-spoiler/anti-emote-spam/anti-newlines` | More protections |
| `automod anti-mentions <name> <bool> [max]` | Mention spam |
| `automod anti-raid <name> <bool> [threshold] [window]` | Raid protection |
| `automod status` | View auto-mod status |
### DM Relay / Invites / Staff / Filters / AutoMod / Starboard / Karma / Highlights / Blackout / Backup / Global
*See `[p]wh help` for the full interactive reference (4 pages).*
### Invites
| Command | Description |
|---------|-------------|
| `invite create <name> [uses] [mins]` | Create invite |
| `invite vanity <name> <word>` | Set vanity URL |
| `invite use <code>` | Join via invite |
| `invite revoke/list` | Manage invites |
### DM Relay
| Command | Description |
|---------|-------------|
| `dm enable/disable <name>` | Toggle DM relay |
| `dm sub/unsub <name>` | Subscribe/unsubscribe |
| `dm send <name> <msg>` | Send via DM |
| `dm list` | List subscriptions |
### Social
| Command | Description |
|---------|-------------|
| `karma enable/disable/check/leaderboard` | Karma system |
| `starboard enable/disable` | Cross-network starboard |
| `highlight add/remove/list` | Keyword notifications |
| `profile [user]` | View user profile |
### Phase 4 — New in v3.3.0
### Phase 4 Features
| Command | Description |
|---------|-------------|
| `mirror add/remove/list` | One-way receive-only channels |
| `poll create/close/list` | Network-wide polls with voting |
| `afk <name> [reason]` | Set AFK status |
| `ignore add/remove/list` | Personal ignore list (DM relay) |
| `autoreply add/add-regex/remove/cooldown/list` | Staff auto-responses |
| `autoreply add/add-regex/remove/list` | Staff auto-responses |
| `bookmark save/list/clear` | Save messages for later |
| `colour <name> <hex>` | Personal embed colour |
| `quiet <name> <start> <end> [offset]` | Quiet hours (DM mute) |
| `quiet-off <name>` | Disable quiet hours |
| `quiet/quiet-off` | Quiet hours (DM mute) |
| `analytics <name>` | Activity dashboard |
| `health <name>` | Channel permission audit |
| `bridge add/remove/list` | Network-to-network bridging |
### Utility
| Command | Description |
|---------|-------------|
| `announce <name> <msg>` | Broadcast to all channels |
| `schedule <name> <mins> <msg>` | Schedule a message |
| `search <name> <query>` | Search message history |
| `discover` | Browse public networks |
| `backup <name>` / `restore` | Backup & restore |
| `debug [name]` | Diagnostic info |
---
## Architecture
@ -160,8 +146,8 @@ EveCogs/
└── wormhole/
├── __init__.py
├── info.json
├── utils.py (403 lines — embeds, filters, automod, helpers)
└── wormhole.py (3,376 lines — main cog, 70+ commands, relay engine)
├── utils.py (419 lines — embeds, filters, automod, mention policy, helpers)
└── wormhole.py (4,196 lines — main cog, 85+ commands, relay engine, context menus)
```
## License

View file

@ -3,6 +3,6 @@
"install_msg": "Welcome to **EveCogs**! Use `[p]cog list` to see available cogs.",
"name": "EveCogs",
"short": "A collection of advanced cogs for Red-DiscordBot.",
"description": "EveCogs is a premium cog repository featuring NexusCore (the ultimate all-in-one server management cog with tickets, applications, suggestions, reaction roles, giveaways, logging, moderation & economy), Wormhole v3.3.0 (cross-server relay), and NewHelpMenu (Components V2 help overhaul).",
"description": "EveCogs is a premium cog repository featuring NexusCore (the ultimate all-in-one server management cog with tickets, applications, suggestions, reaction roles, giveaways, logging, moderation & economy), Wormhole v3.4.0 (cross-server relay with 85+ hybrid commands, context menus, ToS gate, reports, analytics & more), and NewHelpMenu (Components V2 help overhaul).",
"type": "REPO"
}

View file

@ -1,10 +1,10 @@
{
"author": ["everestmcarthur"],
"install_msg": "Thanks for installing **Wormhole v3.3.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\nFull help: `[p]wh help`",
"install_msg": "Thanks for installing **Wormhole v3.4.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\nNew in v3.4.0: Hybrid slash+prefix commands, context menu actions, granular mention policy, Terms of Service gate, mod edit/delete across network, report system.\n\nFull help: `[p]wh help` or `/wh help`",
"name": "Wormhole",
"disabled": false,
"short": "The ultimate cross-server message relay with DM relay, starboard, karma, auto-mod, polls, analytics & more.",
"description": "Wormhole v3.3.0 connects Discord channels across multiple servers into named networks. Messages, edits, deletes, reactions, replies, stickers, pins, typing indicators, and files are all relayed in real-time via webhooks, embeds, or compact text. Features include: bidirectional DM relay, cross-network starboard, reputation/karma system, keyword highlight notifications, auto-moderation engine (spam/caps/invites/links/zalgo/raid detection), network invite codes with vanity URLs, staff announcements, persistent portal embeds, user profiles and stats, backup/restore, blackout scheduling, message search, scheduled messages, per-channel overrides, network discovery, relay delay, slowmode, purge, audit log, attachment filters, global blocklist, anonymous mode, one-way mirror channels, network-wide polls, AFK system, personal ignore lists, auto-responses, ephemeral messages, media-only mode, network analytics dashboard, relay health monitoring, message bookmarks, user vanity colours, quiet hours for DM relay, and inter-network bridging.",
"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.",
"tags": [
"relay",
"crossserver",
@ -21,10 +21,15 @@
"global",
"analytics",
"polls",
"anonymous"
"anonymous",
"hybrid",
"slash",
"contextmenu",
"reports",
"tos"
],
"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, and analytics counters.",
"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.",
"min_bot_version": "3.5.0",
"min_python_version": [3, 9, 0],
"requirements": []

View file

@ -213,6 +213,7 @@ def generate_invite_code(length: int = 8) -> str:
# ── Mention sanitisation ───────────────────────────────────────────────────
def sanitise_mentions(content: str, config: dict) -> str:
"""Legacy mention control — kept for backwards compatibility."""
if config.get("strip_everyone"):
content = content.replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere")
if config.get("strip_role_mentions"):
@ -222,6 +223,21 @@ def sanitise_mentions(content: str, config: dict) -> str:
return content
def apply_mention_policy(content: str, policy: dict, author_id: int, exempt_users: list) -> str:
"""Apply granular mention policy. If the user is exempt, mentions pass through."""
if author_id in exempt_users:
return content
if not policy.get("allow_everyone", False):
content = content.replace("@everyone", "@\u200beveryone")
if not policy.get("allow_here", False):
content = content.replace("@here", "@\u200bhere")
if not policy.get("allow_role_mentions", False):
content = re.sub(r"<@&(\d+)>", r"@\u200brole", content)
if not policy.get("allow_user_mentions", True):
content = re.sub(r"<@!?(\d+)>", r"@\u200buser", content)
return content
# ── Embed builders ──────────────────────────────────────────────────────────
def make_embed(title: str = "", description: str = "", colour: discord.Colour = COLOUR_INFO, **kw) -> discord.Embed:

View file

@ -1,5 +1,5 @@
"""
Wormhole v3.3.0 — The Ultimate Cross-Server Relay Cog for Red-DiscordBot
Wormhole v3.4.0 — The Ultimate Cross-Server Relay Cog for Red-DiscordBot
=========================================================================
Phase 1: Named networks, webhook relay, edit/delete/reply/reaction/sticker sync,
@ -17,6 +17,10 @@ Phase 4: Hardened command filtering, anonymous mode, personal ignore lists,
messages, auto-responses, media-only mode, network analytics,
relay health monitor, message bookmarks, user colours, quiet hours,
network bridging
Phase 5: Hybrid commands (slash + prefix), context menu actions, granular
mention policy (per-network + per-server + per-user), rules/ToS
acceptance gate, mod edit/delete across network, user report system,
privacy & security hardening
"""
from __future__ import annotations
@ -35,6 +39,7 @@ from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional, Set, Tuple
import discord
from discord import app_commands
from redbot.core import Config, checks, commands
from redbot.core.bot import Red
@ -50,6 +55,7 @@ from .utils import (
DuplicateDetector,
RaidDetector,
announce_embed,
apply_mention_policy,
build_dm_incoming_embed,
build_dm_relay_embed,
build_portal_embed,
@ -226,6 +232,23 @@ _DEFAULT_NETWORK = {
# Network bridging (one-way feeds from other networks)
"bridge_from": [], # network names whose messages also appear here
"bridge_to": [], # network names that receive our messages
# ── Phase 5 ──
# Granular mention policy
"mention_policy": {
"allow_user_mentions": True, # allow @user pings to relay as real pings
"allow_role_mentions": False, # allow @role pings
"allow_everyone": False, # allow @everyone
"allow_here": False, # allow @here
},
"server_mention_overrides": {}, # {guild_id_str: {same keys as mention_policy}}
"mention_exempt_users": [], # user IDs allowed to bypass mention policy
# Rules / Terms of Service acceptance gate
"rules_required": False, # must users accept before talking?
"rules_text": "", # the legal ToS text
"rules_accepted": {}, # {user_id_str: accepted_at_iso}
# Report system
"reports": [], # [{id, reporter_id, message_content_hash, reason, channel_id, guild_id, author_id, timestamp, resolved, resolved_by}]
"report_counter": 0, # auto-increment
}
_DEFAULT_GLOBAL = {
@ -268,10 +291,77 @@ class _MessageMap:
return list(self.forward.get(network, {}).get(original_id, {}).values())
class _ReportModal(discord.ui.Modal, title="Report Message"):
"""Modal for reporting a message through the context menu."""
reason = discord.ui.TextInput(
label="Reason for report",
style=discord.TextStyle.paragraph,
placeholder="Describe why you're reporting this message...",
max_length=500,
required=True,
)
def __init__(self, cog: "Wormhole", net_name: str, message: discord.Message):
super().__init__()
self.cog = cog
self.net_name = net_name
self.target = message
async def on_submit(self, interaction: discord.Interaction):
nd = await self.cog._net(self.net_name)
if not nd:
return await interaction.response.send_message("Network not found.", ephemeral=True)
content_hash = hashlib.sha256((self.target.content or "").encode()).hexdigest()[:16]
async with self.cog.config.networks() as n:
counter = n[self.net_name].get("report_counter", 0) + 1
n[self.net_name]["report_counter"] = counter
report = {
"id": counter,
"reporter_id": interaction.user.id,
"author_id": self.target.author.id,
"author_name": str(self.target.author),
"content_preview": truncate(self.target.content or "[no text]", 200),
"content_hash": content_hash,
"reason": str(self.reason)[:500],
"channel_id": self.target.channel.id,
"guild_id": self.target.guild.id if self.target.guild else 0,
"message_id": self.target.id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"resolved": False,
"resolved_by": None,
}
n[self.net_name].setdefault("reports", []).append(report)
if len(n[self.net_name]["reports"]) > 200:
n[self.net_name]["reports"] = n[self.net_name]["reports"][-200:]
self.cog._report_cooldowns[interaction.user.id] = time.time()
# Build embed for log + owner
report_em = discord.Embed(
title=f"🚨 Report #{counter} — {self.net_name}",
colour=discord.Colour.red(),
timestamp=datetime.now(timezone.utc),
)
report_em.add_field(name="Reported User", value=f"{self.target.author} (`{self.target.author.id}`)", inline=True)
report_em.add_field(name="Reporter", value=f"{interaction.user} (`{interaction.user.id}`)", inline=True)
report_em.add_field(name="Reason", value=str(self.reason)[:1024], inline=False)
report_em.add_field(name="Message Content", value=f"```{truncate(self.target.content or '[no text]', 1000)}```", inline=False)
report_em.set_footer(text=f"Content hash: {content_hash}")
await self.cog._log(nd, report_em)
owner = self.cog.bot.get_user(nd.get("owner_id"))
if owner:
try: await owner.send(embed=report_em)
except: pass
await interaction.response.send_message(f"🚨 Report #{counter} submitted. Staff notified.", ephemeral=True)
class Wormhole(commands.Cog):
"""The ultimate cross-server relay: networks, DMs, starboard, auto-mod, invites, portals & more."""
__version__ = "3.3.0"
__version__ = "3.4.0"
def __init__(self, bot: Red):
self.bot = bot
@ -286,6 +376,10 @@ class Wormhole(commands.Cog):
self.msg_map = _MessageMap()
self._autoresponse_cooldowns: Dict[str, float] = {} # "net:pattern" -> last_trigger
self._ephemeral_tasks: List[asyncio.Task] = []
self._report_cooldowns: Dict[int, float] = {} # user_id -> last report timestamp
# Context menus (registered in _init after bot ready)
self._ctx_menus: List[app_commands.ContextMenu] = []
self._ready = asyncio.Event()
self._bg_tasks: List[asyncio.Task] = []
@ -305,17 +399,45 @@ class Wormhole(commands.Cog):
self._bg_tasks.append(asyncio.ensure_future(self._scheduled_msg_loop()))
self._bg_tasks.append(asyncio.ensure_future(self._health_check_loop()))
self._bg_tasks.append(asyncio.ensure_future(self._poll_expiry_loop()))
log.info("Wormhole v3.3.0 ready — %d networks loaded.", len(networks))
# Register context menus
self._register_context_menus()
log.info("Wormhole v3.4.0 ready — %d networks loaded.", len(networks))
except Exception as exc:
log.error("Wormhole init error (relay will still work): %s", exc, exc_info=True)
finally:
# ALWAYS set ready so the relay isn't permanently stuck
self._ready.set()
def _register_context_menus(self):
"""Register right-click context menu actions."""
# ── Report Message ──
report_menu = app_commands.ContextMenu(name="Report to Wormhole", callback=self._ctx_report_message)
self.bot.tree.add_command(report_menu)
self._ctx_menus.append(report_menu)
# ── Bookmark Message ──
bookmark_menu = app_commands.ContextMenu(name="Wormhole Bookmark", callback=self._ctx_bookmark_message)
self.bot.tree.add_command(bookmark_menu)
self._ctx_menus.append(bookmark_menu)
# ── Delete from Network (staff) ──
delete_menu = app_commands.ContextMenu(name="Wormhole Delete", callback=self._ctx_delete_message)
self.bot.tree.add_command(delete_menu)
self._ctx_menus.append(delete_menu)
# ── View Wormhole Profile ──
profile_menu = app_commands.ContextMenu(name="Wormhole Profile", callback=self._ctx_view_profile)
self.bot.tree.add_command(profile_menu)
self._ctx_menus.append(profile_menu)
async def cog_unload(self):
self._startup_task.cancel()
for t in self._bg_tasks:
t.cancel()
# Remove context menus
for menu in self._ctx_menus:
self.bot.tree.remove_command(menu.name, type=menu.type)
self._ctx_menus.clear()
# ── Background loops ────────────────────────────────────────────────────
@ -776,7 +898,7 @@ class Wormhole(commands.Cog):
# ── Main group ──────────────────────────────────────────────────────────
@commands.group(name="wh", aliases=["wormhole"], invoke_without_command=True)
@commands.hybrid_group(name="wh", aliases=["wormhole"], invoke_without_command=True, fallback="help_overview")
async def wh(self, ctx: commands.Context):
"""🌀 Wormhole — the ultimate cross-server relay. Use `[p]wh help` for commands."""
await ctx.send_help(ctx.command)
@ -2735,6 +2857,647 @@ class Wormhole(commands.Cog):
for f in bf: lines.append(f"← **{f}** (incoming)")
await ctx.send(embed=info_embed("\n".join(lines) if lines else "No bridges.", title=f"Bridges — {name}"))
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# PHASE 5 — MENTION POLICY
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@wh.group(name="mentions", invoke_without_command=True)
async def wh_mentions(self, ctx):
"""Granular mention/ping controls (per-network, per-server, per-user)."""
await ctx.send_help(ctx.command)
@wh_mentions.command(name="set")
async def wh_mentions_set(self, ctx, name: str, mention_type: str, toggle: bool):
"""Set network-wide mention policy. Types: users, roles, everyone, here."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return
key_map = {"users": "allow_user_mentions", "roles": "allow_role_mentions",
"everyone": "allow_everyone", "here": "allow_here"}
if mention_type not in key_map:
return await ctx.send(embed=err_embed(f"Invalid type. Choose: {', '.join(key_map.keys())}"))
async with self.config.networks() as n:
n[name].setdefault("mention_policy", {})[key_map[mention_type]] = toggle
state = "allowed" if toggle else "blocked"
await ctx.send(embed=ok_embed(f"**@{mention_type}** mentions are now *{state}* on **{name}**."))
await self._audit(name, "mention_policy", ctx.author, details=f"{mention_type}={toggle}")
@wh_mentions.command(name="server-set")
@commands.guild_only()
async def wh_mentions_server(self, ctx, name: str, mention_type: str, toggle: bool):
"""Override mention policy for THIS server only. Types: users, roles, everyone, here."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return
key_map = {"users": "allow_user_mentions", "roles": "allow_role_mentions",
"everyone": "allow_everyone", "here": "allow_here"}
if mention_type not in key_map:
return await ctx.send(embed=err_embed(f"Invalid type. Choose: {', '.join(key_map.keys())}"))
gid = str(ctx.guild.id)
async with self.config.networks() as n:
overrides = n[name].setdefault("server_mention_overrides", {}).setdefault(gid, {})
overrides[key_map[mention_type]] = toggle
state = "allowed" if toggle else "blocked"
await ctx.send(embed=ok_embed(f"**@{mention_type}** mentions *{state}* for this server on **{name}**."))
@wh_mentions.command(name="exempt")
async def wh_mentions_exempt(self, ctx, name: str, user: discord.User):
"""Allow a user to bypass mention restrictions."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return
async with self.config.networks() as n:
exempt = n[name].setdefault("mention_exempt_users", [])
if user.id not in exempt:
exempt.append(user.id)
await ctx.send(embed=ok_embed(f"**{user.display_name}** can now bypass mention policy on **{name}**."))
@wh_mentions.command(name="unexempt")
async def wh_mentions_unexempt(self, ctx, name: str, user: discord.User):
"""Remove a user's mention exemption."""
name = name.lower()
async with self.config.networks() as n:
if name in n:
exempt = n[name].get("mention_exempt_users", [])
if user.id in exempt:
exempt.remove(user.id)
await ctx.send(embed=ok_embed(f"**{user.display_name}** must follow mention policy now."))
@wh_mentions.command(name="status")
async def wh_mentions_status(self, ctx, name: str):
"""View the current mention policy for a network."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
mp = d.get("mention_policy", {})
sym = lambda v: "✅" if v else "❌"
lines = [
f"**@user mentions:** {sym(mp.get('allow_user_mentions', True))}",
f"**@role mentions:** {sym(mp.get('allow_role_mentions', False))}",
f"**@everyone:** {sym(mp.get('allow_everyone', False))}",
f"**@here:** {sym(mp.get('allow_here', False))}",
]
exempt = d.get("mention_exempt_users", [])
if exempt:
names = [str(self.bot.get_user(uid) or uid) for uid in exempt[:10]]
lines.append(f"\n**Exempt users:** {', '.join(names)}")
overrides = d.get("server_mention_overrides", {})
if overrides:
lines.append(f"\n**Server overrides:** {len(overrides)} server(s)")
for gid_str, ov in list(overrides.items())[:5]:
g = self.bot.get_guild(int(gid_str))
gname = g.name if g else gid_str
ov_lines = ", ".join(f"{k.replace('allow_', '')}={sym(v)}" for k, v in ov.items())
lines.append(f" • {gname}: {ov_lines}")
await ctx.send(embed=info_embed("\n".join(lines), title=f"Mention Policy — {name}"))
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# PHASE 5 — RULES / TERMS OF SERVICE ACCEPTANCE
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
_LEGAL_TEMPLATE = (
"**WORMHOLE NETWORK TERMS OF SERVICE & CODE OF CONDUCT**\n\n"
"By using this cross-server communication network (the \"Network\"), you agree "
"to the following terms. Violation may result in permanent removal and your "
"messages being reported to Discord Trust & Safety and/or law enforcement.\n\n"
"**§1 — Content Standards**\n"
"All content must be appropriate for a general audience (Rated G). You shall NOT:\n"
"• Post, share, or reference NSFW, explicit, or sexually suggestive content\n"
"• Discuss, promote, or reference illegal activities, substances, or services\n"
"• Share content depicting or promoting violence, gore, or self-harm\n"
"• Post personal information (\"doxing\") of any individual\n"
"• Distribute malware, phishing links, or malicious code\n\n"
"**§2 — Conduct & Harassment**\n"
"You shall NOT:\n"
"• Harass, bully, intimidate, or threaten any user\n"
"• Discriminate or use hate speech based on race, ethnicity, gender, sexual "
"orientation, religion, disability, or any protected characteristic\n"
"• Impersonate other users, staff, or Discord employees\n"
"• Spam, flood, or disrupt the network in any way\n"
"• Evade bans, mutes, or other moderation actions\n\n"
"**§3 — Privacy & Data**\n"
"• Your Discord user ID, username, and message metadata are processed for relay "
"functionality and moderation\n"
"• Message content may be logged for moderation and audit purposes\n"
"• Staff may review flagged content; all moderation is logged\n"
"• You consent to your messages being relayed to other servers in this network\n\n"
"**§4 — Intellectual Property**\n"
"• You retain ownership of your content but grant the network a non-exclusive "
"license to relay and display it\n"
"• Do not post copyrighted material without permission\n\n"
"**§5 — Enforcement & Liability**\n"
"• Network staff reserve the right to remove content and ban users at discretion\n"
"• Violations may be reported to Discord and/or relevant authorities\n"
"• Evidence of illegal activity will be preserved and forwarded to law enforcement\n"
"• The network operators are not liable for user-generated content\n"
"• These terms may be updated; continued use constitutes acceptance\n\n"
"**§6 — Agreement**\n"
"By typing the accept command, you acknowledge that you have read, understood, "
"and agree to abide by these terms. You understand that violations may result in "
"permanent removal from the network, reporting to Discord Trust & Safety, and "
"where applicable, referral to law enforcement authorities."
)
@wh.group(name="tos", aliases=["terms"], invoke_without_command=True)
async def wh_tos(self, ctx):
"""Manage the Terms of Service / rules acceptance system."""
await ctx.send_help(ctx.command)
@wh_tos.command(name="enable")
async def wh_tos_enable(self, ctx, name: str):
"""Require users to accept rules before talking. Sets default legal ToS if none exists."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return
async with self.config.networks() as n:
n[name]["rules_required"] = True
if not n[name].get("rules_text"):
n[name]["rules_text"] = self._LEGAL_TEMPLATE
await ctx.send(embed=ok_embed(
f"Rules acceptance is now *required* on **{name}**.\n"
"Users must run `[p]wh accept " + name + "` before they can relay messages.\n"
"Use `[p]wh tos set` to customise the terms."
))
await self._audit(name, "tos_enabled", ctx.author)
@wh_tos.command(name="disable")
async def wh_tos_disable(self, ctx, name: str):
"""Disable rules acceptance requirement."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return
async with self.config.networks() as n:
n[name]["rules_required"] = False
await ctx.send(embed=ok_embed(f"Rules acceptance no longer required on **{name}**."))
@wh_tos.command(name="set")
async def wh_tos_set(self, ctx, name: str, *, text: str):
"""Set custom rules/ToS text (replaces default template)."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return
if len(text) > 4000:
return await ctx.send(embed=err_embed("Text too long (max 4000 chars)."))
async with self.config.networks() as n:
n[name]["rules_text"] = text
await ctx.send(embed=ok_embed(f"ToS updated for **{name}** ({len(text)} chars)."))
@wh_tos.command(name="template")
async def wh_tos_template(self, ctx, name: str):
"""Reset to the built-in legal template."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return
async with self.config.networks() as n:
n[name]["rules_text"] = self._LEGAL_TEMPLATE
await ctx.send(embed=ok_embed(f"ToS reset to default legal template for **{name}**."))
@wh_tos.command(name="accepted")
async def wh_tos_accepted(self, ctx, name: str):
"""View how many users have accepted the ToS."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
accepted = d.get("rules_accepted", {})
lines = [f"**{len(accepted)}** users have accepted the ToS."]
for uid_str, ts in list(accepted.items())[:20]:
u = self.bot.get_user(int(uid_str))
uname = u.display_name if u else uid_str
lines.append(f"• {uname} — {ts[:16]}")
if len(accepted) > 20:
lines.append(f"*...and {len(accepted) - 20} more.*")
await ctx.send(embed=info_embed("\n".join(lines), title=f"ToS Accepted — {name}"))
@wh_tos.command(name="reset")
async def wh_tos_reset(self, ctx, name: str):
"""Clear all acceptances (force everyone to re-accept)."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return
async with self.config.networks() as n:
n[name]["rules_accepted"] = {}
await ctx.send(embed=ok_embed(f"All ToS acceptances cleared for **{name}**. Users must re-accept."))
await self._audit(name, "tos_reset", ctx.author)
@wh.command(name="accept")
async def wh_accept(self, ctx, name: str):
"""View and accept a network's Terms of Service / rules."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
rules_text = d.get("rules_text", "")
if not rules_text:
return await ctx.send(embed=info_embed("This network has no rules set.", title=f"Rules — {name}"))
already = str(ctx.author.id) in d.get("rules_accepted", {})
if already:
return await ctx.send(embed=info_embed("You've already accepted the rules.", title=f"Rules — {name}"))
# Show rules in pages if needed
if len(rules_text) <= 4000:
em = discord.Embed(title=f"📜 Terms of Service — {name}", description=rules_text, colour=COLOUR_INFO)
em.set_footer(text=f"Type '{ctx.clean_prefix}wh agree {name}' to accept these terms.")
await ctx.send(embed=em)
else:
# Split into chunks
for i in range(0, len(rules_text), 4000):
chunk = rules_text[i:i+4000]
em = discord.Embed(description=chunk, colour=COLOUR_INFO)
if i == 0:
em.title = f"📜 Terms of Service — {name}"
await ctx.send(embed=em)
await ctx.send(embed=info_embed(f"Type `{ctx.clean_prefix}wh agree {name}` to accept these terms."))
@wh.command(name="agree")
async def wh_agree(self, ctx, name: str):
"""Confirm acceptance of a network's Terms of Service."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
if not d.get("rules_text"):
return await ctx.send(embed=err_embed("This network has no ToS."))
uid = str(ctx.author.id)
if uid in d.get("rules_accepted", {}):
return await ctx.send(embed=info_embed("You've already accepted."))
async with self.config.networks() as n:
n[name].setdefault("rules_accepted", {})[uid] = datetime.now(timezone.utc).isoformat()
await ctx.send(embed=ok_embed(
f"✅ You have accepted the Terms of Service for **{name}**.\n"
f"Your agreement has been recorded at {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}.\n"
"You may now send messages through this network."
))
await self._audit(name, "tos_accepted", ctx.author)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# PHASE 5 — MOD EDIT / DELETE THROUGH NETWORK
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@wh_mod.command(name="edit")
@commands.guild_only()
async def wh_mod_edit(self, ctx, name: str, message_id: int, *, new_content: str):
"""Edit a message across the entire network. Provide the original message ID."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return
mapping = self.msg_map.get_relayed(name, message_id)
if not mapping:
return await ctx.send(embed=err_embed("Message not found in relay map. Only recent messages can be edited."))
mode = d.get("relay_mode", "webhook")
edited = 0
for ch_id, mid in mapping.items():
ch = self.bot.get_channel(ch_id)
if not ch: continue
try:
cm = self._get_override(d, ch_id, "relay_mode") or mode
if cm == "webhook":
wh = await self._wh(ch)
await wh.edit_message(mid, content=new_content)
else:
msg = await ch.fetch_message(mid)
if msg.author.id == self.bot.user.id:
await msg.edit(content=new_content)
edited += 1
except:
pass
# Also try editing the original message in the source channel
for ch_id in d.get("channels", []):
ch = self.bot.get_channel(ch_id)
if not ch: continue
try:
orig = await ch.fetch_message(message_id)
# Can't edit other users' messages, but note it in the response
break
except:
pass
await ctx.send(embed=ok_embed(f"Edited across **{edited}** relayed copies."))
await self._audit(name, "mod_edit", ctx.author, details=f"msg={message_id}, edited={edited}")
@wh_mod.command(name="nuke", aliases=["network-delete"])
@commands.guild_only()
async def wh_mod_nuke(self, ctx, name: str, message_id: int):
"""Delete a message from the entire network. Provide the original message ID."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return
mapping = self.msg_map.get_relayed(name, message_id)
if not mapping:
return await ctx.send(embed=err_embed("Message not found in relay map. Only recent messages can be deleted."))
mode = d.get("relay_mode", "webhook")
deleted = 0
for ch_id, mid in mapping.items():
ch = self.bot.get_channel(ch_id)
if not ch: continue
try:
cm = self._get_override(d, ch_id, "relay_mode") or mode
if cm == "webhook":
wh = await self._wh(ch)
await wh.delete_message(mid)
else:
msg = await ch.fetch_message(mid)
await msg.delete()
deleted += 1
except:
pass
# Also try deleting the original message
for ch_id in d.get("channels", []):
ch = self.bot.get_channel(ch_id)
if not ch: continue
try:
orig = await ch.fetch_message(message_id)
await orig.delete()
deleted += 1
break
except:
pass
await ctx.send(embed=ok_embed(f"Deleted from **{deleted}** locations across the network."))
await self._audit(name, "mod_network_delete", ctx.author, details=f"msg={message_id}, deleted={deleted}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# PHASE 5 — REPORT SYSTEM
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@wh.group(name="report", invoke_without_command=True)
async def wh_report(self, ctx):
"""Report messages or manage reports."""
await ctx.send_help(ctx.command)
@wh_report.command(name="message")
@commands.guild_only()
async def wh_report_msg(self, ctx, message_id: int = None, *, reason: str = "No reason provided"):
"""Report a message. Reply to a message or provide a message ID."""
# Anti-abuse cooldown (1 report per 60s per user)
now = time.time()
last = self._report_cooldowns.get(ctx.author.id, 0)
if now - last < 60:
return await ctx.send(embed=err_embed(f"Please wait {int(60 - (now - last))}s before reporting again."), delete_after=10)
target = None
if ctx.message.reference and ctx.message.reference.message_id:
try: target = await ctx.channel.fetch_message(ctx.message.reference.message_id)
except: pass
elif message_id:
try: target = await ctx.channel.fetch_message(message_id)
except: pass
if not target:
return await ctx.send(embed=err_embed("Reply to a message or provide a valid message ID."))
# Find network
net_name = await self._net_for_ch(ctx.channel.id)
if not net_name:
return await ctx.send(embed=err_embed("This channel is not part of a wormhole network."))
nd = await self._net(net_name)
if not nd: return
# Create report
content_hash = hashlib.sha256((target.content or "").encode()).hexdigest()[:16]
async with self.config.networks() as n:
counter = n[net_name].get("report_counter", 0) + 1
n[net_name]["report_counter"] = counter
report = {
"id": counter,
"reporter_id": ctx.author.id,
"author_id": target.author.id,
"author_name": str(target.author),
"content_preview": truncate(target.content or "[no text]", 200),
"content_hash": content_hash,
"reason": truncate(reason, 500),
"channel_id": ctx.channel.id,
"guild_id": ctx.guild.id,
"message_id": target.id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"resolved": False,
"resolved_by": None,
}
n[net_name].setdefault("reports", []).append(report)
# Cap at 200 reports
if len(n[net_name]["reports"]) > 200:
n[net_name]["reports"] = n[net_name]["reports"][-200:]
self._report_cooldowns[ctx.author.id] = now
# Notify reporter
await ctx.send(embed=ok_embed(f"Report #{counter} submitted. Staff has been notified."), delete_after=10)
try: await ctx.message.delete()
except: pass
# Send to log channel
report_em = discord.Embed(
title=f"🚨 Report #{counter} — {net_name}",
colour=discord.Colour.red(),
timestamp=datetime.now(timezone.utc),
)
report_em.add_field(name="Reported User", value=f"{target.author} (`{target.author.id}`)", inline=True)
report_em.add_field(name="Reporter", value=f"{ctx.author} (`{ctx.author.id}`)", inline=True)
report_em.add_field(name="Server", value=ctx.guild.name, inline=True)
report_em.add_field(name="Reason", value=reason[:1024], inline=False)
report_em.add_field(name="Message Content", value=f"```{truncate(target.content or '[no text]', 1000)}```", inline=False)
if target.attachments:
report_em.add_field(name="Attachments", value="\n".join(a.url for a in target.attachments[:5]), inline=False)
report_em.set_footer(text=f"Content hash: {content_hash} | Message ID: {target.id}")
await self._log(nd, report_em)
# DM network owner
owner = self.bot.get_user(nd.get("owner_id"))
if owner:
try: await owner.send(embed=report_em)
except: pass
await self._audit(net_name, "report_filed", ctx.author, details=f"#{counter} against {target.author}")
@wh_report.command(name="list")
async def wh_report_list(self, ctx, name: str, show_resolved: bool = False):
"""View reports for a network (staff only)."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return
reports = d.get("reports", [])
if not show_resolved:
reports = [r for r in reports if not r.get("resolved")]
if not reports:
return await ctx.send(embed=info_embed("No reports." if not show_resolved else "No reports at all.", title=f"Reports — {name}"))
lines = []
for r in reversed(reports[-20:]):
status = "✅" if r.get("resolved") else "⏳"
u = self.bot.get_user(r.get("author_id"))
author = u.display_name if u else str(r.get("author_name", "?"))
lines.append(f"{status} **#{r['id']}** — {author}: *{truncate(r.get('reason', ''), 60)}* ({r['timestamp'][:10]})")
await ctx.send(embed=info_embed("\n".join(lines), title=f"Reports — {name}"))
@wh_report.command(name="resolve")
async def wh_report_resolve(self, ctx, name: str, report_id: int):
"""Resolve a report."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return
async with self.config.networks() as n:
reports = n[name].get("reports", [])
for r in reports:
if r["id"] == report_id:
r["resolved"] = True
r["resolved_by"] = ctx.author.id
await ctx.send(embed=ok_embed(f"Report #{report_id} resolved."))
await self._audit(name, "report_resolved", ctx.author, details=f"#{report_id}")
return
await ctx.send(embed=err_embed(f"Report #{report_id} not found."))
@wh_report.command(name="action")
async def wh_report_action(self, ctx, name: str, report_id: int, action: str):
"""Take action on a report: ban, mute, warn, or dismiss."""
name = name.lower(); d = await self._net(name)
if not d: return await ctx.send(embed=err_embed("Not found."))
if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return
report = None
for r in d.get("reports", []):
if r["id"] == report_id:
report = r; break
if not report:
return await ctx.send(embed=err_embed(f"Report #{report_id} not found."))
target_id = report.get("author_id")
action = action.lower()
if action == "ban":
async with self.config.networks() as n:
if target_id not in n[name].get("banned_users", []):
n[name].setdefault("banned_users", []).append(target_id)
await ctx.send(embed=ok_embed(f"User `{target_id}` banned from **{name}**."))
elif action == "mute":
async with self.config.networks() as n:
if target_id not in n[name].get("muted_users", []):
n[name].setdefault("muted_users", []).append(target_id)
await ctx.send(embed=ok_embed(f"User `{target_id}` muted on **{name}**."))
elif action == "warn":
target = self.bot.get_user(target_id)
if target:
try:
await target.send(embed=warn_embed(
f"⚠️ You have received a warning on the **{name}** wormhole network.\n"
f"Reason: {report.get('reason', 'Policy violation')}\n\n"
"Further violations may result in a permanent ban."
))
await ctx.send(embed=ok_embed(f"Warning sent to {target.display_name}."))
except:
await ctx.send(embed=err_embed("Couldn't DM the user."))
else:
await ctx.send(embed=err_embed("User not found."))
elif action == "dismiss":
pass
else:
return await ctx.send(embed=err_embed("Action must be: `ban`, `mute`, `warn`, or `dismiss`."))
# Resolve the report
async with self.config.networks() as n:
for r in n[name].get("reports", []):
if r["id"] == report_id:
r["resolved"] = True
r["resolved_by"] = ctx.author.id
break
await self._audit(name, f"report_action_{action}", ctx.author, details=f"#{report_id} target={target_id}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# PHASE 5 — CONTEXT MENU CALLBACKS
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async def _ctx_report_message(self, interaction: discord.Interaction, message: discord.Message):
"""Context menu: Report a message to Wormhole."""
net_name = await self._net_for_ch(message.channel.id)
if not net_name:
return await interaction.response.send_message("This channel is not part of a wormhole network.", ephemeral=True)
# Anti-abuse cooldown
now = time.time()
last = self._report_cooldowns.get(interaction.user.id, 0)
if now - last < 60:
return await interaction.response.send_message(f"Please wait {int(60 - (now - last))}s.", ephemeral=True)
# Show modal for reason
modal = _ReportModal(self, net_name, message)
await interaction.response.send_modal(modal)
async def _ctx_bookmark_message(self, interaction: discord.Interaction, message: discord.Message):
"""Context menu: Bookmark a message."""
bm = {
"content": truncate(message.content or "[no text]", 500),
"author": str(message.author),
"server": message.guild.name if message.guild else "DM",
"channel": message.channel.name,
"timestamp": message.created_at.isoformat(),
"jump_url": message.jump_url,
}
uid = str(interaction.user.id)
async with self.config.bookmarks() as bookmarks:
bookmarks.setdefault(uid, []).append(bm)
if len(bookmarks[uid]) > 50:
bookmarks[uid] = bookmarks[uid][-50:]
# DM the bookmark
try:
em = info_embed(
f"**{bm['author']}** in #{bm['channel']} ({bm['server']}):\n\n"
f"> {truncate(bm['content'], 300)}\n\n[Jump]({bm['jump_url']})",
title="🔖 Bookmark Saved"
)
await interaction.user.send(embed=em)
except:
pass
await interaction.response.send_message("🔖 Bookmarked! Check your DMs.", ephemeral=True)
async def _ctx_delete_message(self, interaction: discord.Interaction, message: discord.Message):
"""Context menu: Delete a message from the entire network (staff only)."""
net_name = await self._net_for_ch(message.channel.id)
if not net_name:
return await interaction.response.send_message("Not a wormhole channel.", ephemeral=True)
nd = await self._net(net_name)
if not nd:
return await interaction.response.send_message("Network not found.", ephemeral=True)
if not await self._is_staff(nd, interaction.user.id) and not await self.bot.is_owner(interaction.user):
return await interaction.response.send_message("Staff only.", ephemeral=True)
await interaction.response.defer(ephemeral=True)
mapping = self.msg_map.get_relayed(net_name, message.id)
deleted = 0
# Delete relayed copies
if mapping:
mode = nd.get("relay_mode", "webhook")
for ch_id, mid in mapping.items():
ch = self.bot.get_channel(ch_id)
if not ch: continue
try:
cm = self._get_override(nd, ch_id, "relay_mode") or mode
if cm == "webhook":
wh = await self._wh(ch)
await wh.delete_message(mid)
else:
msg = await ch.fetch_message(mid)
await msg.delete()
deleted += 1
except:
pass
# Delete original
try:
await message.delete()
deleted += 1
except:
pass
await interaction.followup.send(f"Deleted from {deleted} location(s).", ephemeral=True)
await self._audit(net_name, "ctx_delete", interaction.user, details=f"msg={message.id}")
async def _ctx_view_profile(self, interaction: discord.Interaction, user: discord.User):
"""Context menu: View a user's Wormhole profile."""
nets = await self.config.networks()
found = []
for name, data in nets.items():
profiles = data.get("user_profiles", {})
uid = str(user.id)
if uid in profiles:
p = profiles[uid]
found.append((name, p))
if not found:
return await interaction.response.send_message(f"{user.display_name} has no Wormhole profiles.", ephemeral=True)
em = discord.Embed(title=f"👤 {user.display_name} — Wormhole Profile", colour=COLOUR_INFO)
em.set_thumbnail(url=user.display_avatar.url)
for name, p in found[:5]:
msgs = p.get("messages", 0)
servers = len(p.get("guilds", []))
karma_data = (await self._net(name) or {}).get("karma_scores", {})
karma = karma_data.get(str(user.id), 0)
em.add_field(
name=name,
value=f"Messages: **{msgs:,}** | Servers: **{servers}** | Karma: **{karma}**",
inline=False,
)
await interaction.response.send_message(embed=em, ephemeral=True)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# DEBUG
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@ -2891,6 +3654,25 @@ class Wormhole(commands.Cog):
if message.guild.id in nd.get("banned_servers", []): return
if message.guild.id in nd.get("muted_servers", []): return
# Rules acceptance gate (Phase 5)
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:
pass
return
# Content filter
if message.content:
if check_filters(message.content, nd.get("word_filters", []), nd.get("regex_filters", [])):
@ -2967,8 +3749,16 @@ class Wormhole(commands.Cog):
# ── Build payload ──────────────────────────────────────────────────
relay_mode = nd.get("relay_mode", "webhook")
nick = nd.get("server_nicknames", {}).get(str(message.guild.id))
mc = nd.get("mention_control", {})
content = sanitise_mentions(message.content or "", mc)
# Mention control — Phase 5 granular policy takes priority, falls back to legacy
mp = nd.get("mention_policy", {})
server_overrides = nd.get("server_mention_overrides", {}).get(str(message.guild.id))
active_policy = server_overrides if server_overrides else mp
exempt = nd.get("mention_exempt_users", [])
if active_policy:
content = apply_mention_policy(message.content or "", active_policy, message.author.id, exempt)
else:
mc = nd.get("mention_control", {})
content = sanitise_mentions(message.content or "", mc)
# Anonymous mode
is_anon = nd.get("anonymous", False)
@ -3322,10 +4112,10 @@ class Wormhole(commands.Cog):
async def wh_help(self, ctx):
"""Full command reference."""
p = ctx.clean_prefix
e1 = discord.Embed(title="🌀 Wormhole v3.2.0 — Commands (1/3)", colour=COLOUR_NEUTRAL,
description="The ultimate cross-server relay: webhooks, DMs, embeds, starboard, auto-mod, invites, karma, portals, highlights, search & more.")
e1 = discord.Embed(title="🌀 Wormhole v3.4.0 — Commands (1/4)", colour=COLOUR_NEUTRAL,
description="The ultimate cross-server relay: hybrid slash+prefix commands, context menus, webhooks, DMs, embeds, starboard, auto-mod, invites, karma, portals, highlights, search & more.")
e1.add_field(name="📡 Networks", value=(
f"`{p}wh create/delete/open/close/list/info/stats`\n"
f"`{p}wh create/delete/open/close/list/info`\n"
f"`{p}wh discover` — public networks\n"
f"`{p}wh transfer <name> @user`\n"
f"`{p}wh announce <name> <msg>` — broadcast\n"
@ -3337,10 +4127,11 @@ class Wormhole(commands.Cog):
f"`relay-mode` `webhooks` `name-mode` `image-mode` `custom-icon` `custom-name`\n"
f"`description` `colour` `ratelimit` `slowmode` `relay-delay` `log-channel` `nickname`\n"
f"`welcome` `motd` `rules` `tags` `public` `max-filesize` `blocked-extensions`\n"
f"`freeze` `silent` `nsfw-gate` `sync-edits/deletes/reactions/replies/stickers/threads/pins/typing`\n"
f"`freeze` `silent` `nsfw-gate` `anonymous` `ephemeral` `media-only`\n"
f"`sync-edits/deletes/reactions/replies/stickers/threads/pins/typing`\n"
f"`forward-embeds` `strip-everyone/roles/users` `channel-override`"), inline=False)
e2 = discord.Embed(title="🌀 Commands (2/3)", colour=COLOUR_NEUTRAL)
e2 = discord.Embed(title="🌀 Commands (2/4)", colour=COLOUR_NEUTRAL)
e2.add_field(name="📧 DM Relay (`wh dm`)", value=(
f"`enable/disable <name>`\n"
f"`mode <name> <embed|compact|plain>`\n"
@ -3356,12 +4147,12 @@ class Wormhole(commands.Cog):
e2.add_field(name="🛡️ Mod (`wh mod`)", value=(
f"`ban/unban/mute/unmute <name> @user`\n"
f"`ban-server/unban-server/mute-server/unmute-server`\n"
f"`allowlist-add/remove` `purge <name> [n]`"), inline=False)
f"`allowlist-add/remove` `purge` `edit` `nuke`"), inline=False)
e2.add_field(name="🤖 Auto-Mod (`wh automod`)", value=(
f"`enable/disable` `anti-spam/mentions/caps/invite/link`\n"
f"`anti-zalgo/spoiler/emote-spam/newlines/raid` `status`"), inline=False)
e3 = discord.Embed(title="🌀 Commands (3/3)", colour=COLOUR_NEUTRAL)
e3 = discord.Embed(title="🌀 Commands (3/4)", colour=COLOUR_NEUTRAL)
e3.add_field(name="🔍 Filters", value="`add-word/remove-word` `add-regex/remove-regex` `list`", inline=True)
e3.add_field(name="⭐ Starboard", value=f"`{p}wh starboard enable <name> #ch [threshold]`\n`disable`", inline=True)
e3.add_field(name="💎 Karma", value=f"`{p}wh karma enable/disable/check/leaderboard`", inline=True)
@ -3371,6 +4162,35 @@ class Wormhole(commands.Cog):
e3.add_field(name="👤 Profiles", value=f"`{p}wh profile <name> [@user]`", inline=True)
e3.add_field(name="📋 Audit", value=f"`{p}wh audit <name> [count]`", inline=True)
e3.add_field(name="🌐 Global", value=f"`{p}wh global ban-user/unban-user/ban-server/unban-server/list`", inline=True)
e3.set_footer(text="Wormhole v3.2.0 • EveCogs • github.com/everestmcarthur/EveCogs")
e3.add_field(name="📊 Phase 4", value=(
f"`mirror add/remove/list` `poll create/close/list` `afk`\n"
f"`ignore add/remove/list` `autoreply add/remove/list`\n"
f"`bookmark save/list/clear` `colour` `quiet/quiet-off`\n"
f"`analytics` `health` `bridge add/remove/list`"), inline=False)
await ctx.send(embeds=[e1, e2, e3])
e4 = discord.Embed(title="🌀 Commands (4/4) — Phase 5", colour=COLOUR_NEUTRAL)
e4.add_field(name="🔔 Mentions (`wh mentions`)", value=(
f"`set <name> <users|roles|everyone|here> <bool>`\n"
f"`server-set <name> <type> <bool>` — per-server override\n"
f"`exempt/unexempt <name> @user` — bypass policy\n"
f"`status <name>` — view current policy"), inline=False)
e4.add_field(name="📜 Terms of Service (`wh tos`)", value=(
f"`enable/disable <name>` — require acceptance\n"
f"`set <name> <text>` — custom ToS text\n"
f"`template <name>` — reset to legal template\n"
f"`accepted <name>` — who accepted\n"
f"`reset <name>` — force re-acceptance\n"
f"`{p}wh accept/agree <name>` — accept ToS"), inline=False)
e4.add_field(name="🚨 Reports (`wh report`)", value=(
f"`message [msg_id] [reason]` — report a message\n"
f"`list <name> [show_resolved]` — view reports\n"
f"`resolve <name> <id>` — resolve a report\n"
f"`action <name> <id> <ban|mute|warn|dismiss>`"), inline=False)
e4.add_field(name="📱 Context Menus (right-click)", value=(
"• **Report to Wormhole** — report a message\n"
"• **Wormhole Bookmark** — save a message\n"
"• **Wormhole Delete** — delete from network (staff)\n"
"• **Wormhole Profile** — view user profile"), inline=False)
e4.set_footer(text="Wormhole v3.4.0 • Hybrid commands (slash + prefix) • EveCogs")
await ctx.send(embeds=[e1, e2, e3, e4])