From 680b87db3984875f1f3dd291a0b53efef45a8255 Mon Sep 17 00:00:00 2001 From: everestmcarthur Date: Thu, 7 May 2026 22:19:09 +0000 Subject: [PATCH] =?UTF-8?q?v3.2.0=20=E2=80=94=20Phase=203:=20DM=20relay,?= =?UTF-8?q?=20starboard,=20karma,=20auto-mod,=20highlights,=20search=20&?= =?UTF-8?q?=20more?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete ground-up rewrite (2,762 lines across 3 files): Phase 3 features: - Bidirectional DM relay (subscribe, send, receive, 3 formats) - Cross-network starboard with configurable threshold - Reputation/karma system with leaderboard - Keyword highlight notifications (DM alerts) - Anti-raid detection with auto-freeze - Attachment filters (blocked extensions, max filesize) - Pin sync across channels - Typing indicator relay - Network discovery (public flag + tags) - Message search across network - Scheduled messages (timed broadcasts) - Network-wide slowmode - Relay delay (configurable) - Vanity invite codes - Per-channel setting overrides - Purge command (mass delete relayed) - Audit log (500 entries, full trail) - MOTD and rules - Comprehensive README with full command reference Phase 2 features (carried forward): - Auto-moderation engine (spam, caps, invites, links, zalgo, spoiler, emote/newline spam) - Invite system with expiry and max uses - Staff announcements and portal messages - Welcome messages and mention control - User profiles and network stats - Backup/restore (JSON export/import) - Blackout scheduling - Global blocklist (bot owner) - Embed and compact relay modes Phase 1 features (carried forward): - Named networks with webhook relay - Edit/delete/reply/reaction/sticker sync - Staff system with ownership transfer - Word and regex content filters - Rate limiting and NSFW gate - Server nicknames, custom identity - Logging channel --- README.md | 250 +++- info.json | 8 +- wormhole/__init__.py | 8 + wormhole/info.json | 36 +- wormhole/utils.py | 356 ++++- wormhole/wormhole.py | 3073 +++++++++++++++++++++++++++--------------- 6 files changed, 2528 insertions(+), 1203 deletions(-) diff --git a/README.md b/README.md index 60bb715..846659d 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,222 @@ -# EveCogs +# ๐ŸŒ€ EveCogs -> A curated collection of advanced, professional-grade cogs for [Red-DiscordBot](https://github.com/Cog-Creators/Red-DiscordBot). +A premium cog repository for [Red-DiscordBot](https://github.com/Cog-Creators/Red-DiscordBot). ---- - -## ๐Ÿ“ฆ Installation +## Installation ``` [p]repo add EveCogs https://github.com/everestmcarthur/EveCogs -[p]cog install EveCogs -[p]load +[p]cog install EveCogs wormhole +[p]load wormhole ``` --- -## ๐ŸŒ€ Wormhole +# ๐ŸŒ€ Wormhole v3.2.0 -**The most advanced cross-server relay system for Red-DiscordBot.** +**The ultimate cross-server message relay for Discord.** -Connect channels across unlimited servers into named networks. Every message, edit, delete, reaction, reply, sticker, embed, and file is relayed in real-time via webhooks โ€” with full moderation, staff roles, filters, rate-limiting, and per-network customisation. +Connect channels across multiple servers into named networks. Messages, edits, deletes, reactions, replies, stickers, pins, typing indicators, and files are relayed in real-time. Features DM relay, starboard, karma, auto-moderation, invites, portals, and 50+ commands. -### Highlights +## โœจ Features -| Feature | Description | -|---|---| -| **Named Networks** | Create unlimited named networks โ€” each with its own config, staff, and channels | -| **Webhook Relay** | Messages appear under the sender's name and avatar (or server icon, or custom) | -| **Edit & Delete Sync** | Edits and deletes propagate across every linked channel instantly | -| **Reply Context** | Replies include a preview of the referenced message | -| **Reaction Sync** | Reactions mirror across channels | -| **Sticker & Embed Forwarding** | Stickers, rich embeds, and file attachments are all relayed | -| **Staff System** | Network owner + unlimited staff with moderation access | -| **User Ban / Mute** | Ban or mute individual users from the network | -| **Server Ban / Mute** | Block or silence entire servers | -| **Server Allowlist** | Restrict which servers may join a network | -| **Word & Regex Filters** | Auto-delete messages that match filters before relay | -| **Rate Limiting** | Token-bucket per-user per-network spam protection | -| **Logging Channel** | Designate a channel to receive moderation logs | -| **Custom Identity** | Name mode (user / server / both / custom template), image mode, custom icon | -| **Server Nicknames** | Override how your server's name appears on the network | -| **Freeze / Pause** | Temporarily halt all relay on a network | -| **Silent Mode** | Suppress join/leave announcements | -| **NSFW Gating** | Block relay from NSFW channels | -| **Thread Support** | Optionally relay messages from threads | -| **Ownership Transfer** | Hand a network to another user | -| **Statistics** | Track total messages relayed, channels linked, and more | +### ๐Ÿ“ก Core Relay +- **Named networks** โ€” create unlimited named relay networks +- **Three relay modes** โ€” `webhook` (rich identity), `embed` (styled cards), `compact` (minimal text) +- **Full sync** โ€” edits, deletes, reactions, replies, stickers, threads, pins, typing indicators +- **File forwarding** โ€” images, videos, documents all relayed automatically +- **Embed forwarding** โ€” rich embeds from bots/links passed through +- **Reply context** โ€” quoted reply previews carried across servers -### Quick Start +### ๐Ÿ“ง DM Relay +- **Subscribe** to receive network messages directly in your DMs +- **Send from DMs** โ€” participate in networks without being in a server channel +- **Three DM formats** โ€” embed, compact, or plain text +- **Bidirectional** โ€” full two-way communication via DMs + +### ๐Ÿ›ก๏ธ Moderation +- **Ban / mute** individual users or entire servers +- **Word filters** โ€” block specific words +- **Regex filters** โ€” pattern-based content blocking +- **Allowlist** โ€” restrict which servers can join +- **Purge** โ€” mass delete relayed messages across all channels +- **Audit log** โ€” full trail of all admin/mod actions (last 500 entries) + +### ๐Ÿค– Auto-Moderation Engine +- **Anti-spam** โ€” duplicate message detection with configurable window/threshold +- **Anti-mention spam** โ€” block mass mentions +- **Anti-caps** โ€” configurable caps percentage threshold +- **Anti-invite** โ€” block Discord invite links +- **Anti-link** โ€” block all URLs +- **Anti-zalgo** โ€” block combining character abuse +- **Anti-spoiler spam** โ€” block excessive spoiler tags +- **Anti-emote spam** โ€” limit custom emoji per message +- **Anti-newline spam** โ€” limit vertical space abuse +- **Anti-raid** โ€” auto-freeze network on flood detection + +### โญ Starboard +- Cross-network starboard โ€” star reactions collected across all channels +- Configurable threshold and dedicated starboard channel +- Auto-updating star count on the board embed + +### ๐Ÿ’Ž Karma / Reputation +- React with a configurable emoji to give karma +- Per-user karma scores tracked across the network +- Leaderboard command + +### ๐Ÿ”” Keyword Highlights +- Subscribe to keywords โ€” get DM'd when they're mentioned +- Per-user, per-network keyword lists + +### ๐Ÿ”— Invites +- **Invite codes** โ€” shareable codes with optional max uses and expiry +- **Vanity invites** โ€” custom words (e.g. `gaming` instead of `xK4mR2pQ`) +- Join networks from any server with just a code + +### ๐Ÿ“ข Communication +- **Staff announcements** โ€” broadcast to all channels + DM subscribers +- **Scheduled messages** โ€” send timed announcements +- **MOTD** โ€” message of the day shown on join +- **Rules** โ€” network rules displayed on demand +- **Welcome messages** โ€” greet new servers automatically + +### ๐ŸŒ€ Portal Messages +- Persistent, auto-updating status embeds pinned in channels +- Shows network status, channel count, features, MOTD +- Refreshes every 5 minutes automatically + +### ๐Ÿ” Search & Discovery +- **Search** โ€” search recent messages across the entire network +- **Network discovery** โ€” list public networks with tags +- **Tags** โ€” categorize networks for easy discovery + +### โš™๏ธ Advanced Configuration +- **Per-channel overrides** โ€” different relay mode per channel +- **Server nicknames** โ€” custom display names per server +- **Custom identity** โ€” custom icons, names, colour accents +- **Name modes** โ€” show user, server, both, or custom template +- **Mention control** โ€” strip @everyone, @here, @role, @user +- **Attachment filters** โ€” block file types and enforce size limits +- **Relay delay** โ€” configurable seconds before relay (for review) +- **Slowmode** โ€” network-wide per-user cooldown +- **Rate limiting** โ€” configurable messages per time window +- **NSFW gate** โ€” block relay from NSFW channels +- **Freeze** โ€” pause a network instantly +- **Silent mode** โ€” suppress join/leave notifications + +### ๐ŸŒ™ Blackout Scheduling +- Auto-freeze/unfreeze on schedule +- Per-day-of-week and hour range (UTC) +- Multiple schedules per network + +### ๐Ÿ’พ Backup & Restore +- Export entire network config as JSON +- Restore to same or different name +- Preserves all settings, filters, profiles + +### ๐Ÿ‘ค User Profiles & Stats +- Per-user message counts, first-seen date, server list +- Network-wide statistics with top-5 leaderboard +- Karma integration in profiles + +### ๐ŸŒ Global Blocklist (Bot Owner) +- Block users/servers from ALL wormhole networks +- Independent of per-network moderation + +### โญ Staff System +- **Owner** โ€” full control, can add/remove staff +- **Staff** โ€” moderation, settings, filters, announcements +- **Ownership transfer** โ€” hand off networks to other users + +## ๐Ÿ“‹ Quick Start ``` -[p]wh create my-network A cool global chat -[p]wh open my-network (run in each channel you want linked) -[p]wh set webhooks my-network true -[p]wh set name-mode my-network both -[p]wh info my-network -[p]wh help +[p]wh create mynetwork A cool cross-server chat! +[p]wh open mynetwork # in each channel you want linked +[p]wh invite create mynetwork # share the code +[p]wh dm enable mynetwork # enable DM relay +[p]wh set relay-mode mynetwork webhook +[p]wh automod enable mynetwork +[p]wh starboard enable mynetwork #starboard 3 +[p]wh karma enable mynetwork ๐Ÿ‘ ``` -### Command Overview +## ๐Ÿ“– Command Reference -| Group | Commands | -|---|---| -| `[p]wh` | `create`, `delete`, `open`, `close`, `list`, `info`, `stats`, `transfer`, `help` | -| `[p]wh set` | `webhooks`, `name-mode`, `image-mode`, `custom-icon`, `custom-name`, `description`, `colour`, `ratelimit`, `log-channel`, `nickname`, `freeze`, `silent`, `nsfw-gate`, `sync-edits`, `sync-deletes`, `sync-reactions`, `sync-replies`, `sync-stickers`, `sync-threads`, `forward-embeds` | -| `[p]wh staff` | `add`, `remove`, `list` | -| `[p]wh mod` | `ban`, `unban`, `mute`, `unmute`, `ban-server`, `unban-server`, `mute-server`, `unmute-server`, `allowlist-add`, `allowlist-remove` | -| `[p]wh filter` | `add-word`, `remove-word`, `add-regex`, `remove-regex`, `list` | +Run `[p]wh help` in Discord for the full interactive reference (3-page embed). ---- +### Network Management +| Command | Description | +|---------|-------------| +| `wh create [desc]` | Create a new network | +| `wh delete ` | Delete a network (with confirmation) | +| `wh open ` | Link current channel | +| `wh close [name]` | Unlink current channel | +| `wh list` | List all networks | +| `wh discover` | List public networks | +| `wh info ` | Detailed network info | +| `wh stats ` | Statistics & leaderboard | +| `wh transfer @user` | Transfer ownership | +| `wh announce ` | Staff broadcast | +| `wh portal [name]` | Create/refresh portal | +| `wh search ` | Search messages | +| `wh schedule ` | Scheduled message | +| `wh rules ` | Show rules | +| `wh motd ` | Show MOTD | +| `wh audit [n]` | View audit log | +| `wh backup ` | Export JSON backup | +| `wh restore [name]` | Import from JSON | + +### DM Relay +| Command | Description | +|---------|-------------| +| `wh dm enable/disable ` | Toggle DM relay | +| `wh dm mode ` | Set DM format | +| `wh dm subscribe ` | Subscribe to DMs | +| `wh dm unsubscribe ` | Unsubscribe | +| `wh dm send ` | Send via DM | +| `wh dm list` | Your subscriptions | + +### Settings (`wh set`) +`relay-mode` ยท `webhooks` ยท `name-mode` ยท `image-mode` ยท `custom-icon` ยท `custom-name` ยท `description` ยท `colour` ยท `ratelimit` ยท `slowmode` ยท `relay-delay` ยท `log-channel` ยท `nickname` ยท `welcome` ยท `motd` ยท `rules` ยท `tags` ยท `public` ยท `max-filesize` ยท `blocked-extensions` ยท `freeze` ยท `silent` ยท `nsfw-gate` ยท `sync-edits` ยท `sync-deletes` ยท `sync-reactions` ยท `sync-replies` ยท `sync-stickers` ยท `sync-threads` ยท `sync-pins` ยท `sync-typing` ยท `forward-embeds` ยท `strip-everyone` ยท `strip-roles` ยท `strip-users` ยท `channel-override` + +### Moderation (`wh mod`) +`ban/unban` ยท `mute/unmute` ยท `ban-server/unban-server` ยท `mute-server/unmute-server` ยท `allowlist-add/remove` ยท `purge` + +### Auto-Moderation (`wh automod`) +`enable/disable` ยท `anti-spam` ยท `anti-mentions` ยท `anti-caps` ยท `anti-invite` ยท `anti-link` ยท `anti-zalgo` ยท `anti-spoiler` ยท `anti-emote-spam` ยท `anti-newlines` ยท `anti-raid` ยท `status` + +### More +| Category | Commands | +|----------|----------| +| **Staff** | `wh staff add/remove/list` | +| **Invites** | `wh invite create/vanity/use/revoke/list` | +| **Filters** | `wh filter add-word/remove-word/add-regex/remove-regex/list` | +| **Starboard** | `wh starboard enable/disable` | +| **Karma** | `wh karma enable/disable/check/leaderboard` | +| **Highlights** | `wh highlight add/remove/list` | +| **Blackout** | `wh blackout add/clear/list` | +| **Profiles** | `wh profile [@user]` | +| **Global** | `wh global ban-user/unban-user/ban-server/unban-server/list` | + +## ๐Ÿ“Š Architecture + +``` +EveCogs/ +โ”œโ”€โ”€ README.md +โ”œโ”€โ”€ LICENSE (MIT) +โ”œโ”€โ”€ info.json (repo metadata) +โ””โ”€โ”€ wormhole/ + โ”œโ”€โ”€ __init__.py (cog loader + data statement) + โ”œโ”€โ”€ info.json (cog metadata) + โ”œโ”€โ”€ utils.py (helpers, embeds, detectors, filters) + โ””โ”€โ”€ wormhole.py (main cog โ€” 2,200+ lines, 50+ commands) +``` ## ๐Ÿ“„ License -MIT โ€” do whatever you want. +MIT โ€” see [LICENSE](LICENSE). ---- +## ๐Ÿค Credits -*Made with โค๏ธ by everestmcarthur* +Built by [everestmcarthur](https://github.com/everestmcarthur) with assistance from Viktor AI. diff --git a/info.json b/info.json index 44bb5f4..a668ac1 100644 --- a/info.json +++ b/info.json @@ -1,6 +1,8 @@ { "author": ["everestmcarthur"], - "description": "EveCogs โ€” a curated collection of advanced, professional-grade cogs for Red-DiscordBot.", - "install_msg": "Thank you for installing **EveCogs**! Browse available cogs with `[p]cog list` and install what you need.", - "short": "Advanced cogs for Red-DiscordBot" + "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 Wormhole โ€” the ultimate cross-server relay system with DM relay, starboard, karma, auto-moderation, invites, portals, and 50+ commands.", + "type": "REPO" } diff --git a/wormhole/__init__.py b/wormhole/__init__.py index c56a9b7..8c50bec 100644 --- a/wormhole/__init__.py +++ b/wormhole/__init__.py @@ -1,5 +1,13 @@ +"""Wormhole โ€” the ultimate cross-server relay cog for Red-DiscordBot.""" + from .wormhole import Wormhole +__red_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, and DM relay subscriptions. " + "Users may request deletion of their data via the bot owner." +) + async def setup(bot): cog = Wormhole(bot) diff --git a/wormhole/info.json b/wormhole/info.json index 2afcb99..99b1ac9 100644 --- a/wormhole/info.json +++ b/wormhole/info.json @@ -1,18 +1,28 @@ { "author": ["everestmcarthur"], - "description": "Wormhole โ€” an advanced cross-server/cross-channel communication relay with named networks, moderation, staff roles, message sync (edits/deletes/replies/reactions), per-network customisation, logging, statistics, filters, rate-limiting, and much more.", - "install_msg": "Thanks for installing **Wormhole**! Run `[p]wh help` to get started.", - "short": "Advanced cross-server relay with networks, moderation & full sync", - "end_user_data_statement": "This cog stores user IDs, guild IDs, channel IDs, and message-mapping data required for relay functionality. No message content is stored persistently.", + "install_msg": "Thanks for installing **Wormhole v3.2.0** โ€” the ultimate cross-server relay!\n\nGet started: `[p]wh create ` then `[p]wh open ` in channels you want to link.\n\nFull help: `[p]wh help`", + "name": "Wormhole", + "disabled": false, + "short": "The ultimate cross-server message relay with DM relay, starboard, karma, auto-mod, invites & more.", + "description": "Wormhole v3.2.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, and a global blocklist.", + "tags": [ + "relay", + "crossserver", + "wormhole", + "chat", + "bridge", + "network", + "webhook", + "moderation", + "automod", + "starboard", + "karma", + "dm", + "global" + ], + "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, and DM relay subscriptions.", "min_bot_version": "3.5.0", "min_python_version": [3, 9, 0], - "tags": [ - "wormhole", - "relay", - "bridge", - "cross-server", - "network", - "moderation", - "webhook" - ] + "requirements": [] } diff --git a/wormhole/utils.py b/wormhole/utils.py index cabbc08..5890f54 100644 --- a/wormhole/utils.py +++ b/wormhole/utils.py @@ -1,21 +1,50 @@ -"""Utility helpers for Wormhole cog.""" +""" +Utility helpers for Wormhole v3.2.0 โ€” the ultimate cross-server relay cog. +""" from __future__ import annotations +import hashlib +import json +import random import re +import string import time from collections import defaultdict -from typing import Optional +from datetime import datetime, timezone +from typing import Dict, List, Optional, Set, Tuple import discord # โ”€โ”€ Colour palette โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -COLOUR_OK = discord.Colour.from_rgb(87, 242, 135) # green -COLOUR_WARN = discord.Colour.from_rgb(254, 231, 92) # yellow -COLOUR_ERR = discord.Colour.from_rgb(237, 66, 69) # red -COLOUR_INFO = discord.Colour.from_rgb(88, 101, 242) # blurple -COLOUR_NEUTRAL = discord.Colour.from_rgb(114, 137, 218) # light blurple +COLOUR_OK = discord.Colour.from_rgb(87, 242, 135) +COLOUR_WARN = discord.Colour.from_rgb(254, 231, 92) +COLOUR_ERR = discord.Colour.from_rgb(237, 66, 69) +COLOUR_INFO = discord.Colour.from_rgb(88, 101, 242) +COLOUR_NEUTRAL = discord.Colour.from_rgb(114, 137, 218) +COLOUR_ANNOUNCE = discord.Colour.from_rgb(255, 163, 26) +COLOUR_STAR = discord.Colour.from_rgb(255, 172, 51) +COLOUR_DM = discord.Colour.from_rgb(87, 165, 242) + +_SERVER_COLOURS = [ + discord.Colour.from_rgb(231, 76, 60), + discord.Colour.from_rgb(46, 204, 113), + discord.Colour.from_rgb(52, 152, 219), + discord.Colour.from_rgb(155, 89, 182), + discord.Colour.from_rgb(241, 196, 15), + discord.Colour.from_rgb(26, 188, 156), + discord.Colour.from_rgb(230, 126, 34), + discord.Colour.from_rgb(233, 30, 99), + discord.Colour.from_rgb(0, 188, 212), + discord.Colour.from_rgb(139, 195, 74), + discord.Colour.from_rgb(255, 87, 34), + discord.Colour.from_rgb(121, 85, 72), +] + + +def server_colour(guild_id: int) -> discord.Colour: + return _SERVER_COLOURS[guild_id % len(_SERVER_COLOURS)] # โ”€โ”€ Cooldown bucket โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -31,9 +60,7 @@ class CooldownBucket: def is_rate_limited(self, user_id: int, network: str) -> bool: key = f"{network}:{user_id}" now = time.monotonic() - bucket = self._buckets[key] - # Purge old timestamps - self._buckets[key] = [t for t in bucket if now - t < self.per] + self._buckets[key] = [t for t in self._buckets[key] if now - t < self.per] if len(self._buckets[key]) >= self.rate: return True self._buckets[key].append(now) @@ -47,7 +74,6 @@ class CooldownBucket: # โ”€โ”€ Filter helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def check_filters(content: str, word_filters: list[str], regex_filters: list[str]) -> Optional[str]: - """Return the first matched filter pattern, or None if clean.""" lowered = content.lower() for word in word_filters: if word.lower() in lowered: @@ -61,32 +87,286 @@ def check_filters(content: str, word_filters: list[str], regex_filters: list[str return None +# โ”€โ”€ Auto-moderation helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +INVITE_PATTERN = re.compile( + r"(discord\.gg|discord\.com/invite|discordapp\.com/invite|dsc\.gg)/[\w-]+", + re.IGNORECASE, +) +LINK_PATTERN = re.compile(r"https?://\S+", re.IGNORECASE) +MENTION_PATTERN = re.compile(r"<@!?\d+>|<@&\d+>|@everyone|@here") + +BLOCKED_EXTENSIONS = { + ".exe", ".bat", ".cmd", ".scr", ".pif", ".msi", ".com", + ".vbs", ".vbe", ".js", ".jse", ".wsf", ".wsh", ".ps1", +} + + +def check_automod(content: str, automod_config: dict) -> Optional[str]: + if not automod_config: + return None + if automod_config.get("anti_invite") and INVITE_PATTERN.search(content): + return "Discord invite link detected" + if automod_config.get("anti_link") and LINK_PATTERN.search(content): + return "Link detected" + if automod_config.get("anti_mention_spam"): + max_mentions = automod_config.get("max_mentions", 5) + if len(MENTION_PATTERN.findall(content)) > max_mentions: + return f"Too many mentions" + if automod_config.get("anti_caps"): + threshold = automod_config.get("caps_threshold", 0.7) + alpha = [c for c in content if c.isalpha()] + if len(alpha) >= 10: + ratio = sum(1 for c in alpha if c.isupper()) / len(alpha) + if ratio >= threshold: + return f"Excessive caps ({ratio:.0%})" + if automod_config.get("anti_zalgo"): + # Detect zalgo text (combining characters) + combining = sum(1 for c in content if '\u0300' <= c <= '\u036f' or '\u0489' <= c <= '\u0489') + if combining > 10: + return "Zalgo text detected" + if automod_config.get("anti_spoiler"): + spoiler_count = content.count("||") + if spoiler_count >= 6: # 3+ spoiler blocks + return "Excessive spoiler tags" + if automod_config.get("anti_emote_spam"): + max_emotes = automod_config.get("max_emotes", 10) + emote_count = len(re.findall(r"", content)) + if emote_count > max_emotes: + return f"Too many custom emotes ({emote_count})" + if automod_config.get("anti_newline_spam"): + max_newlines = automod_config.get("max_newlines", 15) + if content.count("\n") > max_newlines: + return "Too many newlines" + return None + + +def check_attachment_filters( + attachments: list, + blocked_exts: Optional[set] = None, + max_filesize: Optional[int] = None, +) -> Optional[str]: + """Check attachments against extension and size filters.""" + exts = blocked_exts or set() + for att in attachments: + name_lower = att.filename.lower() + for ext in exts: + if name_lower.endswith(ext): + return f"Blocked file type: {ext}" + if max_filesize and att.size > max_filesize: + mb = max_filesize / (1024 * 1024) + return f"File too large (max {mb:.1f} MB)" + return None + + +class DuplicateDetector: + """Detects duplicate/spam messages per user per network.""" + + def __init__(self, window: float = 30.0, threshold: int = 3): + self.window = window + self.threshold = threshold + self._history: Dict[Tuple[str, int], List[Tuple[str, float]]] = defaultdict(list) + + def is_duplicate(self, network: str, user_id: int, content: str) -> bool: + key = (network, user_id) + now = time.monotonic() + content_hash = hashlib.md5(content.lower().strip().encode()).hexdigest() + self._history[key] = [(h, t) for h, t in self._history[key] if now - t < self.window] + dup_count = sum(1 for h, _ in self._history[key] if h == content_hash) + self._history[key].append((content_hash, now)) + if len(self._history[key]) > 50: + self._history[key] = self._history[key][-25:] + return dup_count >= self.threshold + + def update(self, window: float, threshold: int): + self.window = window + self.threshold = threshold + + +class RaidDetector: + """Detects raid patterns: many new unique users posting in short window.""" + + def __init__(self, window: float = 60.0, user_threshold: int = 10): + self.window = window + self.user_threshold = user_threshold + # {network: [(user_id, timestamp), ...]} + self._activity: Dict[str, List[Tuple[int, float]]] = defaultdict(list) + + def record(self, network: str, user_id: int) -> bool: + """Record activity. Returns True if raid detected.""" + now = time.monotonic() + self._activity[network] = [ + (uid, t) for uid, t in self._activity[network] if now - t < self.window + ] + self._activity[network].append((user_id, now)) + unique = len(set(uid for uid, _ in self._activity[network])) + return unique >= self.user_threshold + + +# โ”€โ”€ Invite code generation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def generate_invite_code(length: int = 8) -> str: + chars = string.ascii_letters + string.digits + return "".join(random.choices(chars, k=length)) + + +# โ”€โ”€ Mention sanitisation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def sanitise_mentions(content: str, config: dict) -> str: + if config.get("strip_everyone"): + content = content.replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere") + if config.get("strip_role_mentions"): + content = re.sub(r"<@&(\d+)>", r"@role", content) + if config.get("strip_user_mentions"): + content = re.sub(r"<@!?(\d+)>", r"@user", content) + return content + + # โ”€โ”€ Embed builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -def make_embed( - title: str = "", - description: str = "", - colour: discord.Colour = COLOUR_INFO, - **kwargs, +def make_embed(title: str = "", description: str = "", colour: discord.Colour = COLOUR_INFO, **kw) -> discord.Embed: + return discord.Embed(title=title, description=description, colour=colour, **kw) + +def ok_embed(desc: str, title: str = "โœ… Success") -> discord.Embed: + return make_embed(title=title, description=desc, colour=COLOUR_OK) + +def err_embed(desc: str, title: str = "โŒ Error") -> discord.Embed: + return make_embed(title=title, description=desc, colour=COLOUR_ERR) + +def warn_embed(desc: str, title: str = "โš ๏ธ Warning") -> discord.Embed: + return make_embed(title=title, description=desc, colour=COLOUR_WARN) + +def info_embed(desc: str, title: str = "โ„น๏ธ Info") -> discord.Embed: + return make_embed(title=title, description=desc, colour=COLOUR_INFO) + +def announce_embed(desc: str, title: str = "๐Ÿ“ข Announcement") -> discord.Embed: + return make_embed(title=title, description=desc, colour=COLOUR_ANNOUNCE) + +def star_embed(desc: str, title: str = "โญ Starred") -> discord.Embed: + return make_embed(title=title, description=desc, colour=COLOUR_STAR) + +def dm_embed(desc: str, title: str = "โœ‰๏ธ DM Relay") -> discord.Embed: + return make_embed(title=title, description=desc, colour=COLOUR_DM) + + +# โ”€โ”€ Embed relay builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def build_relay_embed( + message: discord.Message, + server_nick: Optional[str] = None, + net_colour: Optional[int] = None, ) -> discord.Embed: - em = discord.Embed(title=title, description=description, colour=colour, **kwargs) + guild_label = server_nick or message.guild.name + colour = discord.Colour(net_colour) if net_colour else server_colour(message.guild.id) + + em = discord.Embed(description=message.content or "*[no text]*", colour=colour, timestamp=message.created_at) + em.set_author(name=message.author.display_name, icon_url=message.author.display_avatar.url) + footer_icon = message.guild.icon.url if message.guild.icon else discord.Embed.Empty + em.set_footer(text=f"๐ŸŒ {guild_label} โ€ข #{message.channel.name}", icon_url=footer_icon) + + if message.attachments: + img_set = False + file_lines = [] + for att in message.attachments: + if att.content_type and att.content_type.startswith("image/") and not img_set: + em.set_image(url=att.url) + img_set = True + else: + file_lines.append(f"[{att.filename}]({att.url})") + if file_lines: + em.add_field(name="๐Ÿ“Ž Attachments", value="\n".join(file_lines), inline=False) + + if message.stickers: + em.add_field(name="๐ŸŽจ Stickers", value=", ".join(f"[{s.name}]({s.url})" for s in message.stickers), inline=False) + return em -def ok_embed(description: str, title: str = "โœ… Success") -> discord.Embed: - return make_embed(title=title, description=description, colour=COLOUR_OK) +def build_dm_relay_embed( + user: discord.User, + content: str, + network_name: str, +) -> discord.Embed: + """Build an embed for messages sent from DM to the network.""" + em = discord.Embed(description=content, colour=COLOUR_DM, timestamp=datetime.now(timezone.utc)) + em.set_author(name=f"{user.display_name} (via DM)", icon_url=user.display_avatar.url) + em.set_footer(text=f"โœ‰๏ธ DM โ†’ {network_name}") + return em -def err_embed(description: str, title: str = "โŒ Error") -> discord.Embed: - return make_embed(title=title, description=description, colour=COLOUR_ERR) +def build_dm_incoming_embed( + author_name: str, + author_avatar: str, + server_name: str, + channel_name: str, + content: str, + network_name: str, + net_colour: Optional[int] = None, +) -> discord.Embed: + """Build an embed for network messages forwarded to DM subscribers.""" + colour = discord.Colour(net_colour) if net_colour else COLOUR_INFO + em = discord.Embed(description=content or "*[no text]*", colour=colour, timestamp=datetime.now(timezone.utc)) + em.set_author(name=author_name, icon_url=author_avatar) + em.set_footer(text=f"๐ŸŒ {server_name} โ€ข #{channel_name} โ€ข {network_name}") + return em -def warn_embed(description: str, title: str = "โš ๏ธ Warning") -> discord.Embed: - return make_embed(title=title, description=description, colour=COLOUR_WARN) +# โ”€โ”€ Portal embed builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def build_portal_embed( + network_name: str, data: dict, channel_count: int, total_messages: int, +) -> discord.Embed: + colour = discord.Colour(data["colour"]) if data.get("colour") else COLOUR_INFO + desc = data.get("description") or "*A wormhole network.*" + status = "โ„๏ธ Frozen" if data.get("frozen") else "๐ŸŸข Active" + + em = discord.Embed(title=f"๐ŸŒ€ Wormhole โ€” {network_name}", description=desc, colour=colour) + em.add_field(name="Status", value=status, inline=True) + em.add_field(name="Channels", value=str(channel_count), inline=True) + em.add_field(name="Messages", value=f"{total_messages:,}", inline=True) + + dm_count = len(data.get("dm_subscribers", [])) + if dm_count: + em.add_field(name="DM Subscribers", value=str(dm_count), inline=True) + + features = [] + for key, label in [ + ("use_webhooks", "Webhooks"), ("sync_edits", "Edit sync"), + ("sync_deletes", "Delete sync"), ("sync_reactions", "Reaction sync"), + ("sync_replies", "Reply sync"), ("dm_enabled", "DM relay"), + ]: + if data.get(key): + features.append(f"โœ… {label}") + if features: + em.add_field(name="Features", value=" โ€ข ".join(features), inline=False) + + if data.get("motd"): + em.add_field(name="๐Ÿ“‹ MOTD", value=data["motd"][:256], inline=False) + if data.get("custom_icon"): + em.set_thumbnail(url=data["custom_icon"]) + em.set_footer(text="Portal auto-updates โ€ข Wormhole v3.2.0") + em.timestamp = datetime.now(timezone.utc) + return em -def info_embed(description: str, title: str = "โ„น๏ธ Info") -> discord.Embed: - return make_embed(title=title, description=description, colour=COLOUR_INFO) +# โ”€โ”€ Starboard embed โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def build_star_embed( + author_name: str, + author_avatar: str, + content: str, + stars: int, + server_name: str, + channel_name: str, + image_url: Optional[str] = None, +) -> discord.Embed: + em = discord.Embed(description=content, colour=COLOUR_STAR, timestamp=datetime.now(timezone.utc)) + em.set_author(name=author_name, icon_url=author_avatar) + em.add_field(name="โญ Stars", value=str(stars), inline=True) + em.set_footer(text=f"{server_name} โ€ข #{channel_name}") + if image_url: + em.set_image(url=image_url) + return em # โ”€โ”€ Formatting helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -96,18 +376,26 @@ def truncate(text: str, length: int = 2000) -> str: return text return text[: length - 3] + "..." - def human_timedelta(seconds: float) -> str: m, s = divmod(int(seconds), 60) h, m = divmod(m, 60) d, h = divmod(h, 24) parts = [] - if d: - parts.append(f"{d}d") - if h: - parts.append(f"{h}h") - if m: - parts.append(f"{m}m") - if s or not parts: - parts.append(f"{s}s") + if d: parts.append(f"{d}d") + if h: parts.append(f"{h}h") + if m: parts.append(f"{m}m") + if s or not parts: parts.append(f"{s}s") return " ".join(parts) + +def compact_format(server_name: str, user_name: str, content: str) -> str: + return truncate(f"**[{server_name}] {user_name}:** {content}", 2000) + +def format_audit_entry(action: str, user: str, target: str = "", details: str = "") -> dict: + """Create an audit log entry.""" + return { + "timestamp": datetime.now(timezone.utc).isoformat(), + "action": action, + "user": user, + "target": target, + "details": details, + } diff --git a/wormhole/wormhole.py b/wormhole/wormhole.py index b0661f8..9d94d40 100644 --- a/wormhole/wormhole.py +++ b/wormhole/wormhole.py @@ -1,29 +1,26 @@ """ -Wormhole โ€” Advanced Cross-Server Relay Cog for Red-DiscordBot -============================================================== +Wormhole v3.2.0 โ€” The Ultimate Cross-Server Relay Cog for Red-DiscordBot +========================================================================= -Features --------- -โ€ข Named wormhole networks with full per-network config -โ€ข Webhook relay with custom avatar/name modes (user, server, both, custom) -โ€ข Message-edit, message-delete, reply, reaction, thread & sticker sync -โ€ข Staff system โ€” owner + staff per network with granular perms -โ€ข Moderation โ€” user ban/mute, server ban, word filter, regex filter -โ€ข Rate limiting (token-bucket per user per network) -โ€ข Per-network logging channel -โ€ข Statistics dashboard (messages relayed, top users, top servers) -โ€ข Server allowlist / blocklist per network -โ€ข NSFW gating, freeze/pause, silent mode -โ€ข Custom network icon, description, colour -โ€ข Nickname overrides per server -โ€ข Transfer network ownership -โ€ข Full help embeds +Phase 1: Named networks, webhook relay, edit/delete/reply/reaction/sticker sync, + staff system, moderation, filters, rate limiting, logging, stats +Phase 2: Embed/compact relay modes, auto-moderation, invites, announcements, + portals, welcome messages, mention control, user profiles, backup/restore, + blackout scheduling, global blocklist +Phase 3: DM relay (bidirectional), pin sync, starboard, audit log, anti-raid, + attachment filters, reputation/karma, MOTD/rules, relay delay, vanity + invites, keyword highlights/notifications, network roles, per-channel + overrides, scheduled messages, purge, typing indicators, network + discovery, message search, slowmode """ from __future__ import annotations import asyncio +import io +import json import logging +import re import time from collections import defaultdict from copy import deepcopy @@ -35,15 +32,35 @@ from redbot.core import Config, checks, commands from redbot.core.bot import Red from .utils import ( + BLOCKED_EXTENSIONS, + COLOUR_ANNOUNCE, + COLOUR_DM, COLOUR_INFO, COLOUR_NEUTRAL, COLOUR_OK, + COLOUR_STAR, CooldownBucket, + DuplicateDetector, + RaidDetector, + announce_embed, + build_dm_incoming_embed, + build_dm_relay_embed, + build_portal_embed, + build_relay_embed, + build_star_embed, + check_attachment_filters, + check_automod, check_filters, + compact_format, + dm_embed, err_embed, + format_audit_entry, + generate_invite_code, human_timedelta, info_embed, ok_embed, + sanitise_mentions, + star_embed, truncate, warn_embed, ) @@ -57,23 +74,24 @@ _DEFAULT_NETWORK = { "owner_id": 0, "staff_ids": [], # channels - "channels": [], # list of channel IDs + "channels": [], # identity "use_webhooks": True, - "image_mode": "user", # user | server | custom - "name_mode": "both", # user | server | both | custom - "custom_icon": None, # URL - "custom_name": None, # fallback name template - "colour": None, # hex int for embed accent + "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": [], # user IDs - "banned_servers": [], # guild IDs - "muted_users": [], # user IDs - "muted_servers": [], # guild IDs + "banned_users": [], + "banned_servers": [], + "muted_users": [], + "muted_servers": [], "word_filters": [], "regex_filters": [], - "allowlist_servers": [], # if non-empty only these guilds may join + "allowlist_servers": [], # features "sync_edits": True, "sync_deletes": True, @@ -81,39 +99,109 @@ _DEFAULT_NETWORK = { "sync_replies": True, "sync_threads": False, "sync_stickers": True, + "sync_pins": False, "forward_embeds": True, - "nsfw_gate": True, # block NSFW channel content relay - "silent": False, # suppress join/leave announcements - "frozen": False, # pause all relay + "nsfw_gate": True, + "silent": False, + "frozen": False, # rate limit - "rate_limit_rate": 5, # messages - "rate_limit_per": 10.0, # seconds + "rate_limit_rate": 5, + "rate_limit_per": 10.0, # logging "log_channel": None, - # server nicknames {guild_id_str: "nickname"} + # server nicknames "server_nicknames": {}, # stats "total_messages": 0, - "created_at": None, # ISO timestamp + "created_at": None, + # โ”€โ”€ Phase 2 โ”€โ”€ + "automod": { + "enabled": False, + "anti_spam": False, + "anti_mention_spam": False, + "anti_caps": False, + "anti_invite": False, + "anti_link": False, + "anti_zalgo": False, + "anti_spoiler": False, + "anti_emote_spam": False, + "anti_newline_spam": False, + "anti_raid": False, + "max_mentions": 5, + "caps_threshold": 0.7, + "spam_window": 30.0, + "spam_threshold": 3, + "max_emotes": 10, + "max_newlines": 15, + "raid_window": 60.0, + "raid_threshold": 10, + }, + "invites": {}, + "vanity_invite": None, # custom word invite + "portal_messages": {}, + "welcome_message": "", + "mention_control": { + "strip_everyone": True, + "strip_role_mentions": False, + "strip_user_mentions": False, + }, + "user_profiles": {}, + "blackout_schedules": [], + # โ”€โ”€ Phase 3 โ”€โ”€ + # DM relay + "dm_enabled": False, + "dm_subscribers": [], # user IDs + "dm_relay_mode": "embed", # embed | compact | plain + # Starboard + "starboard_enabled": False, + "starboard_channel": None, + "starboard_threshold": 3, + "starred_messages": {}, # {original_msg_id: {stars: int, board_msg_id: int}} + # Audit log + "audit_log": [], # list of audit entries (capped at 500) + # Attachment filters + "blocked_extensions": [], + "max_filesize": None, # bytes + # Reputation / Karma + "karma_enabled": False, + "karma_emoji": "๐Ÿ‘", + "karma_scores": {}, # {user_id_str: int} + # MOTD / Rules + "motd": "", + "rules": "", + # Relay delay (seconds, 0 = instant) + "relay_delay": 0, + # Keyword highlights (DM notifications) + "highlights": {}, # {user_id_str: [keywords]} + # Network roles + "roles": {}, # {role_name: {perms: [...], members: [user_ids]}} + # Per-channel overrides + "channel_overrides": {}, # {channel_id_str: {key: value}} + # Scheduled messages + "scheduled_messages": [], # [{content, send_at_iso, author_id}] + # Slowmode (network-level, seconds between messages per user) + "slowmode": 0, + # Network discovery + "public": False, # show in wh discover + "tags": [], + # Typing indicator relay + "sync_typing": False, } _DEFAULT_GLOBAL = { - "networks": {}, # {network_name: _DEFAULT_NETWORK} + "networks": {}, "max_networks_per_user": 10, + "global_banned_users": [], + "global_banned_servers": [], } -# message map: stores {original_msg_id: {channel_id: relayed_msg_id}} -# Kept in memory only (not persisted โ€” too noisy). Evicts after 2 000 entries. _MAP_LIMIT = 2_000 +_AUDIT_LIMIT = 500 class _MessageMap: - """In-memory bidirectional message-ID mapping per network.""" - def __init__(self): - # network -> original_msg_id -> {channel_id: relayed_msg_id} self.forward: Dict[str, Dict[int, Dict[int, int]]] = defaultdict(dict) - # network -> relayed_msg_id -> original_msg_id self.reverse: Dict[str, Dict[int, int]] = defaultdict(dict) self._order: Dict[str, list] = defaultdict(list) @@ -122,7 +210,6 @@ class _MessageMap: for ch_id, msg_id in mapping.items(): self.reverse[network][msg_id] = original_id self._order[network].append(original_id) - # Evict while len(self._order[network]) > _MAP_LIMIT: old = self._order[network].pop(0) old_map = self.forward[network].pop(old, {}) @@ -140,9 +227,9 @@ class _MessageMap: class Wormhole(commands.Cog): - """Advanced cross-server relay with networks, moderation & full sync.""" + """The ultimate cross-server relay: networks, DMs, starboard, auto-mod, invites, portals & more.""" - __version__ = "3.0.0" + __version__ = "3.2.0" def __init__(self, bot: Red): self.bot = bot @@ -150,129 +237,290 @@ class Wormhole(commands.Cog): self.config.register_global(**_DEFAULT_GLOBAL) self.webhook_cache: Dict[int, discord.Webhook] = {} - self.cooldowns: Dict[str, CooldownBucket] = {} # per network + self.cooldowns: Dict[str, CooldownBucket] = {} + self.slowmode_tracker: Dict[str, Dict[int, float]] = defaultdict(dict) # {network: {user_id: last_time}} + self.dup_detectors: Dict[str, DuplicateDetector] = {} + self.raid_detectors: Dict[str, RaidDetector] = {} self.msg_map = _MessageMap() self._ready = asyncio.Event() + self._bg_tasks: List[asyncio.Task] = [] self._startup_task = self.bot.loop.create_task(self._init()) async def _init(self): await self.bot.wait_until_ready() - # Warm cooldown buckets networks = await self.config.networks() for name, data in networks.items(): - self.cooldowns[name] = CooldownBucket( - data.get("rate_limit_rate", 5), - data.get("rate_limit_per", 10.0), - ) + self.cooldowns[name] = CooldownBucket(data.get("rate_limit_rate", 5), data.get("rate_limit_per", 10.0)) + am = data.get("automod", {}) + self.dup_detectors[name] = DuplicateDetector(am.get("spam_window", 30.0), am.get("spam_threshold", 3)) + self.raid_detectors[name] = RaidDetector(am.get("raid_window", 60.0), am.get("raid_threshold", 10)) + self._bg_tasks.append(self.bot.loop.create_task(self._blackout_loop())) + self._bg_tasks.append(self.bot.loop.create_task(self._portal_update_loop())) + self._bg_tasks.append(self.bot.loop.create_task(self._scheduled_msg_loop())) self._ready.set() - log.info("Wormhole cog ready โ€” %d networks loaded.", len(networks)) + log.info("Wormhole v3.2.0 ready โ€” %d networks loaded.", len(networks)) async def cog_unload(self): self._startup_task.cancel() - # Clean up webhooks we created? No โ€” leave them for next load. + for t in self._bg_tasks: + t.cancel() + + # โ”€โ”€ Background loops โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + async def _blackout_loop(self): + await self._ready.wait() + while True: + try: + await asyncio.sleep(60) + now_utc = datetime.now(timezone.utc) + h, d = now_utc.hour, now_utc.weekday() + async with self.config.networks() as networks: + for name, data in networks.items(): + scheds = data.get("blackout_schedules", []) + if not scheds: + continue + should = False + for s in scheds: + if d in s.get("days", list(range(7))): + st, en = s.get("start_hour", 0), s.get("end_hour", 0) + if st <= en: + should = should or (st <= h < en) + else: + should = should or (h >= st or h < en) + if should and not data.get("frozen"): + networks[name]["frozen"] = True + elif not should and data.get("frozen"): + networks[name]["frozen"] = False + except asyncio.CancelledError: + break + except Exception as e: + log.error("Blackout loop: %s", e) + await asyncio.sleep(60) + + async def _portal_update_loop(self): + await self._ready.wait() + while True: + try: + await asyncio.sleep(300) + networks = await self.config.networks() + for name, data in networks.items(): + portals = data.get("portal_messages", {}) + if not portals: + continue + embed = build_portal_embed(name, data, len(data["channels"]), data.get("total_messages", 0)) + dead = [] + for cid_s, mid in portals.items(): + ch = self.bot.get_channel(int(cid_s)) + if not ch: + dead.append(cid_s); continue + try: + msg = await ch.fetch_message(mid) + await msg.edit(embed=embed) + except discord.NotFound: + dead.append(cid_s) + except Exception: + pass + if dead: + async with self.config.networks() as nets: + if name in nets: + for c in dead: + nets[name].get("portal_messages", {}).pop(c, None) + except asyncio.CancelledError: + break + except Exception as e: + log.error("Portal loop: %s", e) + await asyncio.sleep(300) + + async def _scheduled_msg_loop(self): + await self._ready.wait() + while True: + try: + await asyncio.sleep(30) + now = datetime.now(timezone.utc) + async with self.config.networks() as networks: + for name, data in networks.items(): + sched = data.get("scheduled_messages", []) + remaining = [] + for msg_data in sched: + send_at = datetime.fromisoformat(msg_data["send_at"]) + if now >= send_at: + # Send it + for ch_id in data["channels"]: + ch = self.bot.get_channel(ch_id) + if ch: + try: + em = announce_embed(msg_data["content"], title=f"๐Ÿ“… Scheduled โ€” {name}") + await ch.send(embed=em) + except Exception: + pass + else: + remaining.append(msg_data) + networks[name]["scheduled_messages"] = remaining + except asyncio.CancelledError: + break + except Exception as e: + log.error("Scheduled msg loop: %s", e) + await asyncio.sleep(30) # โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - async def _get_network(self, name: str) -> Optional[dict]: - networks = await self.config.networks() - return networks.get(name) + async def _net(self, name: str) -> Optional[dict]: + return (await self.config.networks()).get(name) - async def _save_network(self, name: str, data: dict): - async with self.config.networks() as networks: - networks[name] = data + async def _save(self, name: str, data: dict): + async with self.config.networks() as n: + n[name] = data - async def _network_for_channel(self, channel_id: int) -> Optional[str]: - """Return the network name a channel belongs to, or None.""" - networks = await self.config.networks() - for name, data in networks.items(): - if channel_id in data.get("channels", []): - return name + async def _net_for_ch(self, ch_id: int) -> Optional[str]: + for n, d in (await self.config.networks()).items(): + if ch_id in d.get("channels", []): + return n return None - async def _get_webhook(self, channel: discord.TextChannel) -> discord.Webhook: - if channel.id in self.webhook_cache: - # Validate it's still alive - try: - return self.webhook_cache[channel.id] - except Exception: - pass + async def _wh(self, ch: discord.TextChannel) -> discord.Webhook: + if ch.id in self.webhook_cache: + return self.webhook_cache[ch.id] try: - webhooks = await channel.webhooks() - for wh in webhooks: - if wh.user == self.bot.user and wh.name == "Wormhole Relay": - self.webhook_cache[channel.id] = wh - return wh - wh = await channel.create_webhook(name="Wormhole Relay") - self.webhook_cache[channel.id] = wh - return wh + for w in await ch.webhooks(): + if w.user == self.bot.user and w.name == "Wormhole Relay": + self.webhook_cache[ch.id] = w + return w + w = await ch.create_webhook(name="Wormhole Relay") + self.webhook_cache[ch.id] = w + return w except discord.Forbidden: - raise commands.UserFeedbackCheckFailure( - "I don't have permission to manage webhooks in that channel." - ) + raise commands.UserFeedbackCheckFailure("Missing webhook permissions.") - def _resolve_avatar(self, message: discord.Message, mode: str, custom_icon: Optional[str]) -> str: + def _avatar(self, msg, mode, icon): if mode == "server": - icon = message.guild.icon - return icon.url if icon else (message.author.display_avatar.url) - if mode == "custom" and custom_icon: - return custom_icon - return message.author.display_avatar.url + i = msg.guild.icon + return i.url if i else msg.author.display_avatar.url + if mode == "custom" and icon: + return icon + return msg.author.display_avatar.url - def _resolve_name( - self, message: discord.Message, mode: str, custom_name: Optional[str], - server_nick: Optional[str] = None, - ) -> str: - guild_label = server_nick or message.guild.name - user_label = message.author.display_name - if mode == "server": - return guild_label - if mode == "both": - return f"{guild_label} โ€ข {user_label}" - if mode == "custom" and custom_name: - return custom_name.replace("{user}", user_label).replace("{server}", guild_label) - return user_label # default "user" + def _name(self, msg, mode, custom, nick=None): + g = nick or msg.guild.name + u = msg.author.display_name + if mode == "server": return g + if mode == "both": return f"{g} โ€ข {u}" + if mode == "custom" and custom: return custom.replace("{user}", u).replace("{server}", g) + return u - async def _is_staff(self, network_data: dict, user_id: int) -> bool: - return user_id == network_data["owner_id"] or user_id in network_data.get("staff_ids", []) + async def _is_staff(self, d, uid): return uid == d["owner_id"] or uid in d.get("staff_ids", []) + async def _is_owner(self, d, uid): return uid == d["owner_id"] - async def _is_owner(self, network_data: dict, user_id: int) -> bool: - return user_id == network_data["owner_id"] + def _get_override(self, net_data, ch_id, key): + """Get per-channel override or fall back to network setting.""" + overrides = net_data.get("channel_overrides", {}).get(str(ch_id), {}) + if key in overrides: + return overrides[key] + return net_data.get(key) - async def _log_event(self, network_data: dict, embed: discord.Embed): - ch_id = network_data.get("log_channel") - if not ch_id: - return - ch = self.bot.get_channel(ch_id) - if ch: - try: - await ch.send(embed=embed) - except Exception: - pass - - async def _relay_status(self, network_name: str, network_data: dict, source_channel: discord.Channel, text: str): - """Send a status/announcement embed to every channel in the network except source.""" - if network_data.get("silent"): - return - em = info_embed(text, title=f"๐ŸŒ€ {network_name}") - for ch_id in network_data["channels"]: - if ch_id == source_channel.id: - continue + async def _log(self, d, embed): + ch_id = d.get("log_channel") + if ch_id: ch = self.bot.get_channel(ch_id) if ch: - try: - await ch.send(embed=em) - except Exception: - pass + try: await ch.send(embed=embed) + except Exception: pass + + async def _audit(self, name, action, user, target="", details=""): + entry = format_audit_entry(action, str(user), target, details) + async with self.config.networks() as nets: + if name in nets: + al = nets[name].setdefault("audit_log", []) + al.append(entry) + if len(al) > _AUDIT_LIMIT: + nets[name]["audit_log"] = al[-_AUDIT_LIMIT:] + + async def _status(self, net_name, net_data, src_ch, text): + if net_data.get("silent"): return + em = info_embed(text, title=f"๐ŸŒ€ {net_name}") + for ch_id in net_data["channels"]: + if ch_id == (src_ch.id if src_ch else 0): continue + ch = self.bot.get_channel(ch_id) + if ch: + try: await ch.send(embed=em) + except Exception: pass + + async def _update_profile(self, name, user, guild_id): + uid = str(user.id) + async with self.config.networks() as nets: + if name not in nets: return + p = nets[name].setdefault("user_profiles", {}) + if uid not in p: + p[uid] = {"messages": 0, "first_seen": datetime.now(timezone.utc).isoformat(), "servers": []} + p[uid]["messages"] += 1 + if guild_id not in p[uid].get("servers", []): + p[uid].setdefault("servers", []).append(guild_id) + + async def _check_highlights(self, net_name, net_data, content, author_id): + """DM users who have keyword highlights matching this message.""" + highlights = net_data.get("highlights", {}) + if not highlights or not content: + return + lower = content.lower() + for uid_str, keywords in highlights.items(): + uid = int(uid_str) + if uid == author_id: + continue + for kw in keywords: + if kw.lower() in lower: + user = self.bot.get_user(uid) + if user: + try: + em = info_embed( + f"Keyword **{kw}** was mentioned in **{net_name}**:\n\n> {truncate(content, 300)}", + title="๐Ÿ”” Highlight" + ) + await user.send(embed=em) + except Exception: + pass + break # one DM per message per user + + async def _relay_to_dm_subs(self, net_name, net_data, message): + """Forward a network message to all DM subscribers.""" + if not net_data.get("dm_enabled"): + return + subs = net_data.get("dm_subscribers", []) + if not subs: + return + dm_mode = net_data.get("dm_relay_mode", "embed") + for uid in subs: + if uid == message.author.id: + continue + user = self.bot.get_user(uid) + if not user: + continue + try: + nick = net_data.get("server_nicknames", {}).get(str(message.guild.id)) + if dm_mode == "embed": + em = build_dm_incoming_embed( + message.author.display_name, + message.author.display_avatar.url, + nick or message.guild.name, + message.channel.name, + message.content, + net_name, + net_data.get("colour"), + ) + await user.send(embed=em) + elif dm_mode == "compact": + server = nick or message.guild.name + text = f"**[{server}] {message.author.display_name}:** {truncate(message.content, 1800)}" + await user.send(text) + else: + await user.send(f"**{message.author.display_name}** ({message.guild.name}): {truncate(message.content, 1800)}") + except Exception: + pass # โ”€โ”€ Main group โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @commands.group(name="wh", aliases=["wormhole"], invoke_without_command=True) async def wh(self, ctx: commands.Context): - """๐ŸŒ€ Wormhole โ€” advanced cross-server relay. - - Use `[p]wh help` for the full command list. - """ + """๐ŸŒ€ Wormhole โ€” the ultimate cross-server relay. Use `[p]wh help` for commands.""" await ctx.send_help(ctx.command) # โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” @@ -280,200 +528,175 @@ class Wormhole(commands.Cog): # โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” @wh.command(name="create") - async def wh_create(self, ctx: commands.Context, name: str, *, description: str = ""): - """Create a new wormhole network. - - **name** must be alphanumeric + hyphens, max 32 chars. - """ + async def wh_create(self, ctx, name: str, *, description: str = ""): + """Create a new wormhole network.""" name = name.lower().strip() if not name.replace("-", "").replace("_", "").isalnum() or len(name) > 32: - return await ctx.send(embed=err_embed("Network name must be alphanumeric (hyphens/underscores OK), max 32 chars.")) - - networks = await self.config.networks() - if name in networks: - return await ctx.send(embed=err_embed(f"Network **{name}** already exists.")) - - max_nets = await self.config.max_networks_per_user() - owned = sum(1 for n in networks.values() if n["owner_id"] == ctx.author.id) - if owned >= max_nets: - return await ctx.send(embed=err_embed(f"You already own {owned}/{max_nets} networks.")) - - data = deepcopy(_DEFAULT_NETWORK) - data["owner_id"] = ctx.author.id - data["description"] = description - data["created_at"] = datetime.now(timezone.utc).isoformat() - - await self._save_network(name, data) - self.cooldowns[name] = CooldownBucket(data["rate_limit_rate"], data["rate_limit_per"]) - - em = ok_embed( + return await ctx.send(embed=err_embed("Name: alphanumeric/hyphens/underscores, max 32 chars.")) + nets = await self.config.networks() + if name in nets: + return await ctx.send(embed=err_embed(f"**{name}** already exists.")) + mx = await self.config.max_networks_per_user() + if sum(1 for n in nets.values() if n["owner_id"] == ctx.author.id) >= mx: + return await ctx.send(embed=err_embed(f"You own the max of {mx} networks.")) + d = deepcopy(_DEFAULT_NETWORK) + d["owner_id"] = ctx.author.id + d["description"] = description + d["created_at"] = datetime.now(timezone.utc).isoformat() + await self._save(name, d) + self.cooldowns[name] = CooldownBucket(d["rate_limit_rate"], d["rate_limit_per"]) + self.dup_detectors[name] = DuplicateDetector() + self.raid_detectors[name] = RaidDetector() + p = ctx.clean_prefix + await ctx.send(embed=ok_embed( f"Network **{name}** created!\n\n" - f"โ€ข Add channels: `{ctx.clean_prefix}wh open {name}`\n" - f"โ€ข Customise: `{ctx.clean_prefix}wh set {name}