From 6ca37b9ba1fc81437049c5ad983834e4e0187a5b Mon Sep 17 00:00:00 2001 From: everestmcarthur Date: Thu, 7 May 2026 21:53:21 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20Wormhole=20v3.0.?= =?UTF-8?q?0:=20advanced=20cross-server=20relay=20cog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Named wormhole networks with full per-network config - Webhook relay with custom avatar/name modes (user, server, both, custom) - Message edit, delete, reply, reaction, thread & sticker sync - Staff system (owner + staff) with granular permissions - Moderation: user ban/mute, server ban/mute, allowlist - Word & regex content filters with auto-delete - Token-bucket rate limiting per user per network - Per-network logging channel - Statistics dashboard - NSFW gating, freeze/pause, silent mode - Custom network icon, description, accent colour - Server nickname overrides - Ownership transfer - Thread support - Embed forwarding - Professional README, info.json, LICENSE --- LICENSE | 21 + README.md | 78 +++ info.json | 6 + wormhole/__init__.py | 6 + wormhole/info.json | 18 + wormhole/utils.py | 113 ++++ wormhole/wormhole.py | 1474 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1716 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 info.json create mode 100644 wormhole/__init__.py create mode 100644 wormhole/info.json create mode 100644 wormhole/utils.py create mode 100644 wormhole/wormhole.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8098ba0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 everestmcarthur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..60bb715 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# EveCogs + +> A curated collection of advanced, professional-grade cogs for [Red-DiscordBot](https://github.com/Cog-Creators/Red-DiscordBot). + +--- + +## đŸ“Ļ Installation + +``` +[p]repo add EveCogs https://github.com/everestmcarthur/EveCogs +[p]cog install EveCogs +[p]load +``` + +--- + +## 🌀 Wormhole + +**The most advanced cross-server relay system for Red-DiscordBot.** + +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. + +### Highlights + +| 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 | + +### 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 +``` + +### Command Overview + +| 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` | + +--- + +## 📄 License + +MIT — do whatever you want. + +--- + +*Made with â¤ī¸ by everestmcarthur* diff --git a/info.json b/info.json new file mode 100644 index 0000000..44bb5f4 --- /dev/null +++ b/info.json @@ -0,0 +1,6 @@ +{ + "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" +} diff --git a/wormhole/__init__.py b/wormhole/__init__.py new file mode 100644 index 0000000..c56a9b7 --- /dev/null +++ b/wormhole/__init__.py @@ -0,0 +1,6 @@ +from .wormhole import Wormhole + + +async def setup(bot): + cog = Wormhole(bot) + await bot.add_cog(cog) diff --git a/wormhole/info.json b/wormhole/info.json new file mode 100644 index 0000000..2afcb99 --- /dev/null +++ b/wormhole/info.json @@ -0,0 +1,18 @@ +{ + "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.", + "min_bot_version": "3.5.0", + "min_python_version": [3, 9, 0], + "tags": [ + "wormhole", + "relay", + "bridge", + "cross-server", + "network", + "moderation", + "webhook" + ] +} diff --git a/wormhole/utils.py b/wormhole/utils.py new file mode 100644 index 0000000..cabbc08 --- /dev/null +++ b/wormhole/utils.py @@ -0,0 +1,113 @@ +"""Utility helpers for Wormhole cog.""" + +from __future__ import annotations + +import re +import time +from collections import defaultdict +from typing import Optional + +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 + + +# ── Cooldown bucket ──────────────────────────────────────────────────────── + +class CooldownBucket: + """Per-user per-network token-bucket rate limiter.""" + + def __init__(self, rate: int = 5, per: float = 10.0): + self.rate = rate + self.per = per + self._buckets: dict[str, list[float]] = defaultdict(list) + + 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] + if len(self._buckets[key]) >= self.rate: + return True + self._buckets[key].append(now) + return False + + def update(self, rate: int, per: float): + self.rate = rate + self.per = per + + +# ── 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: + return word + for pattern in regex_filters: + try: + if re.search(pattern, content, re.IGNORECASE): + return pattern + except re.error: + continue + return None + + +# ── Embed builders ────────────────────────────────────────────────────────── + +def make_embed( + title: str = "", + description: str = "", + colour: discord.Colour = COLOUR_INFO, + **kwargs, +) -> discord.Embed: + em = discord.Embed(title=title, description=description, colour=colour, **kwargs) + return em + + +def ok_embed(description: str, title: str = "✅ Success") -> discord.Embed: + return make_embed(title=title, description=description, colour=COLOUR_OK) + + +def err_embed(description: str, title: str = "❌ Error") -> discord.Embed: + return make_embed(title=title, description=description, colour=COLOUR_ERR) + + +def warn_embed(description: str, title: str = "âš ī¸ Warning") -> discord.Embed: + return make_embed(title=title, description=description, colour=COLOUR_WARN) + + +def info_embed(description: str, title: str = "â„šī¸ Info") -> discord.Embed: + return make_embed(title=title, description=description, colour=COLOUR_INFO) + + +# ── Formatting helpers ────────────────────────────────────────────────────── + +def truncate(text: str, length: int = 2000) -> str: + if len(text) <= length: + 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") + return " ".join(parts) diff --git a/wormhole/wormhole.py b/wormhole/wormhole.py new file mode 100644 index 0000000..b0661f8 --- /dev/null +++ b/wormhole/wormhole.py @@ -0,0 +1,1474 @@ +""" +Wormhole — Advanced 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 +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from collections import defaultdict +from copy import deepcopy +from datetime import datetime, timezone +from typing import Dict, List, Optional, Set + +import discord +from redbot.core import Config, checks, commands +from redbot.core.bot import Red + +from .utils import ( + COLOUR_INFO, + COLOUR_NEUTRAL, + COLOUR_OK, + CooldownBucket, + check_filters, + err_embed, + human_timedelta, + info_embed, + ok_embed, + truncate, + warn_embed, +) + +log = logging.getLogger("red.evecogs.wormhole") + +# ── Default config structures ────────────────────────────────────────────── + +_DEFAULT_NETWORK = { + # ownership / staff + "owner_id": 0, + "staff_ids": [], + # channels + "channels": [], # list of channel IDs + # 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 + "description": "", + # moderation + "banned_users": [], # user IDs + "banned_servers": [], # guild IDs + "muted_users": [], # user IDs + "muted_servers": [], # guild IDs + "word_filters": [], + "regex_filters": [], + "allowlist_servers": [], # if non-empty only these guilds may join + # features + "sync_edits": True, + "sync_deletes": True, + "sync_reactions": True, + "sync_replies": True, + "sync_threads": False, + "sync_stickers": True, + "forward_embeds": True, + "nsfw_gate": True, # block NSFW channel content relay + "silent": False, # suppress join/leave announcements + "frozen": False, # pause all relay + # rate limit + "rate_limit_rate": 5, # messages + "rate_limit_per": 10.0, # seconds + # logging + "log_channel": None, + # server nicknames {guild_id_str: "nickname"} + "server_nicknames": {}, + # stats + "total_messages": 0, + "created_at": None, # ISO timestamp +} + +_DEFAULT_GLOBAL = { + "networks": {}, # {network_name: _DEFAULT_NETWORK} + "max_networks_per_user": 10, +} + +# 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 + + +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) + + def add(self, network: str, original_id: int, mapping: Dict[int, int]): + self.forward[network][original_id] = mapping + for ch_id, msg_id in mapping.items(): + self.reverse[network][msg_id] = original_id + self._order[network].append(original_id) + # Evict + while len(self._order[network]) > _MAP_LIMIT: + old = self._order[network].pop(0) + old_map = self.forward[network].pop(old, {}) + for _ch, _mid in old_map.items(): + self.reverse[network].pop(_mid, None) + + def get_relayed(self, network: str, original_id: int) -> Dict[int, int]: + return self.forward.get(network, {}).get(original_id, {}) + + def get_original(self, network: str, relayed_id: int) -> Optional[int]: + return self.reverse.get(network, {}).get(relayed_id) + + def get_all_relayed_ids(self, network: str, original_id: int) -> List[int]: + return list(self.forward.get(network, {}).get(original_id, {}).values()) + + +class Wormhole(commands.Cog): + """Advanced cross-server relay with networks, moderation & full sync.""" + + __version__ = "3.0.0" + + def __init__(self, bot: Red): + self.bot = bot + self.config = Config.get_conf(self, identifier=7730187301, force_registration=True) + self.config.register_global(**_DEFAULT_GLOBAL) + + self.webhook_cache: Dict[int, discord.Webhook] = {} + self.cooldowns: Dict[str, CooldownBucket] = {} # per network + self.msg_map = _MessageMap() + + self._ready = asyncio.Event() + 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._ready.set() + log.info("Wormhole cog 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. + + # ── Helpers ───────────────────────────────────────────────────────────── + + async def _get_network(self, name: str) -> Optional[dict]: + networks = await self.config.networks() + return networks.get(name) + + async def _save_network(self, name: str, data: dict): + async with self.config.networks() as networks: + networks[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 + 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 + 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 + except discord.Forbidden: + raise commands.UserFeedbackCheckFailure( + "I don't have permission to manage webhooks in that channel." + ) + + def _resolve_avatar(self, message: discord.Message, mode: str, custom_icon: Optional[str]) -> str: + 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 + + 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" + + 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_owner(self, network_data: dict, user_id: int) -> bool: + return user_id == network_data["owner_id"] + + 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 + ch = self.bot.get_channel(ch_id) + if ch: + try: + await ch.send(embed=em) + 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. + """ + await ctx.send_help(ctx.command) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # NETWORK MANAGEMENT + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @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. + """ + 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( + f"Network **{name}** created!\n\n" + f"â€ĸ Add channels: `{ctx.clean_prefix}wh open {name}`\n" + f"â€ĸ Customise: `{ctx.clean_prefix}wh set {name}