diff --git a/wormhole/__init__.py b/wormhole/__init__.py index 8c50bec..c94ab4e 100644 --- a/wormhole/__init__.py +++ b/wormhole/__init__.py @@ -1,6 +1,6 @@ -"""Wormhole — the ultimate cross-server relay cog for Red-DiscordBot.""" +"""Wormhole v4.0.0 — the ultimate cross-server relay cog for Red-DiscordBot.""" -from .wormhole import Wormhole +from .core import Wormhole __red_end_user_data_statement__ = ( "This cog stores Discord user IDs, guild IDs, and channel IDs for relay functionality. " @@ -12,3 +12,4 @@ __red_end_user_data_statement__ = ( async def setup(bot): cog = Wormhole(bot) await bot.add_cog(cog) + await cog._init() diff --git a/wormhole/commands/__init__.py b/wormhole/commands/__init__.py new file mode 100644 index 0000000..940dc99 --- /dev/null +++ b/wormhole/commands/__init__.py @@ -0,0 +1,31 @@ +"""Command modules — each is a mixin class inherited by the main Wormhole cog.""" + +from .advanced import AdvancedCommands +from .bridge import BridgeCommands +from .debug import DebugCommands +from .dm import DMCommands +from .filters import FilterCommands +from .mentions import MentionCommands +from .moderation import ModerationCommands +from .network import NetworkCommands +from .reports import ReportCommands +from .settings import SettingsCommands +from .social import SocialCommands +from .staff import StaffCommands +from .tos import ToSCommands + +__all__ = [ + "AdvancedCommands", + "BridgeCommands", + "DebugCommands", + "DMCommands", + "FilterCommands", + "MentionCommands", + "ModerationCommands", + "NetworkCommands", + "ReportCommands", + "SettingsCommands", + "SocialCommands", + "StaffCommands", + "ToSCommands", +] diff --git a/wormhole/commands/advanced.py b/wormhole/commands/advanced.py new file mode 100644 index 0000000..4e858f0 --- /dev/null +++ b/wormhole/commands/advanced.py @@ -0,0 +1,435 @@ +"""Advanced commands — polls, AFK, auto-responses, scheduled messages, +analytics, ephemeral, quiet hours, user colours, invites, announcements.""" + +from __future__ import annotations + +import secrets +from datetime import datetime, timedelta, timezone +from typing import Optional + +import discord +from redbot.core import commands + +from ..models.permissions import Role, requires_role, has_role +from ..utils import ( + ok_embed, err_embed, info_embed, warn_embed, announce_embed, + truncate, generate_invite_code, human_timedelta, COLOUR_INFO, +) + + +class AdvancedCommands: + """Mixin — advanced features.""" + + # ── Polls ────────────────────────────────────────────────────────────── + + @commands.group(name="wh-poll", aliases=["whpoll"], invoke_without_command=True) + async def wh_poll(self, ctx: commands.Context) -> None: + """Network-wide polls.""" + await ctx.send_help(ctx.command) + + @wh_poll.command(name="create") + @requires_role(Role.MODERATOR) + async def wh_poll_create(self, ctx: commands.Context, name: str, duration_minutes: int, question: str, *, options: str) -> None: + """Create a poll. Options separated by ``|``. + + Example: ``wh poll create mynet 60 Favourite colour? Red | Blue | Green`` + """ + option_list = [o.strip() for o in options.split("|") if o.strip()] + if len(option_list) < 2: + return await ctx.send(embed=err_embed("Need at least 2 options separated by `|`.")) + if len(option_list) > 10: + return await ctx.send(embed=err_embed("Maximum 10 options.")) + expires = (datetime.now(timezone.utc) + timedelta(minutes=duration_minutes)).isoformat() + poll_id = secrets.token_hex(4) + poll = { + "question": question, + "options": option_list, + "votes": {}, + "expires": expires, + "creator": ctx.author.id, + } + async with self.config.networks() as ns: + ns[name].setdefault("active_polls", {})[poll_id] = poll + + emojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"] + desc = "\n".join(f"{emojis[i]} {opt}" for i, opt in enumerate(option_list)) + desc += f"\n\nVote with `wh poll vote {name} {poll_id} `\nExpires in {duration_minutes} minutes." + em = discord.Embed(title=f"📊 {question}", description=desc, colour=COLOUR_INFO) + + nd = await self._net(name) + for ch_id in nd.get("channels", []): + ch = self.bot.get_channel(ch_id) + if ch: + try: + await ch.send(embed=em) + except Exception: + pass + await ctx.send(embed=ok_embed(f"Poll `{poll_id}` created in `{name}`.")) + + @wh_poll.command(name="vote") + async def wh_poll_vote(self, ctx: commands.Context, name: str, poll_id: str, choice: int) -> None: + """Vote in a poll (choice = option number, 1-based).""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + poll = nd.get("active_polls", {}).get(poll_id) + if not poll: + return await ctx.send(embed=err_embed("Poll not found or expired.")) + if choice < 1 or choice > len(poll["options"]): + return await ctx.send(embed=err_embed(f"Choose 1–{len(poll['options'])}.")) + idx = str(choice - 1) + uid = ctx.author.id + async with self.config.networks() as ns: + p = ns[name]["active_polls"][poll_id] + # Remove previous vote + for k, voters in p.get("votes", {}).items(): + if uid in voters: + voters.remove(uid) + p.setdefault("votes", {}).setdefault(idx, []).append(uid) + await ctx.send(embed=ok_embed(f"Voted for **{poll['options'][choice-1]}**."), delete_after=5) + + @wh_poll.command(name="results") + async def wh_poll_results(self, ctx: commands.Context, name: str, poll_id: str) -> None: + """View current poll results.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + poll = nd.get("active_polls", {}).get(poll_id) + if not poll: + return await ctx.send(embed=err_embed("Poll not found.")) + results = self._format_poll_results(poll) + await ctx.send(embed=info_embed(results, title=f"📊 {poll['question']}")) + + @wh_poll.command(name="end") + @requires_role(Role.MODERATOR) + async def wh_poll_end(self, ctx: commands.Context, name: str, poll_id: str) -> None: + """End a poll early.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + poll = nd.get("active_polls", {}).get(poll_id) + if not poll: + return await ctx.send(embed=err_embed("Poll not found.")) + results = self._format_poll_results(poll) + async with self.config.networks() as ns: + ns[name].get("active_polls", {}).pop(poll_id, None) + for ch_id in nd.get("channels", []): + ch = self.bot.get_channel(ch_id) + if ch: + try: + await ch.send(embed=info_embed(results, title=f"📊 Poll Ended: {poll['question']}")) + except Exception: + pass + await ctx.send(embed=ok_embed("Poll ended.")) + + # ── AFK ──────────────────────────────────────────────────────────────── + + @commands.hybrid_command(name="wh-afk") + async def wh_afk(self, ctx: commands.Context, name: str, *, reason: str = "AFK") -> None: + """Set your AFK status in a network.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + async with self.config.networks() as ns: + ns[name].setdefault("afk_users", {})[str(ctx.author.id)] = { + "reason": reason, + "since_iso": datetime.now(timezone.utc).isoformat(), + } + await ctx.send(embed=ok_embed(f"AFK set: **{reason}**")) + + # ── Auto-responses ───────────────────────────────────────────────────── + + @commands.group(name="wh-ar", aliases=["whar"], invoke_without_command=True) + async def wh_ar(self, ctx: commands.Context) -> None: + """Auto-response triggers.""" + await ctx.send_help(ctx.command) + + @wh_ar.command(name="add") + @requires_role(Role.ADMIN) + async def wh_ar_add(self, ctx: commands.Context, name: str, trigger: str, *, reply: str) -> None: + """Add an auto-response. Use ``regex:pattern`` for regex triggers.""" + is_regex = trigger.startswith("regex:") + if is_regex: + trigger = trigger[6:] + import re + try: + re.compile(trigger) + except re.error as e: + return await ctx.send(embed=err_embed(f"Invalid regex: {e}")) + async with self.config.networks() as ns: + ns[name].setdefault("auto_responses", {})[trigger] = { + "reply": reply, + "regex": is_regex, + "cooldown": 30, + "last_used": 0, + } + await ctx.send(embed=ok_embed(f"Auto-response added for `{trigger}`.")) + + @wh_ar.command(name="remove", aliases=["rm"]) + @requires_role(Role.ADMIN) + async def wh_ar_rm(self, ctx: commands.Context, name: str, *, trigger: str) -> None: + """Remove an auto-response.""" + async with self.config.networks() as ns: + ns[name].get("auto_responses", {}).pop(trigger, None) + await ctx.send(embed=ok_embed(f"Auto-response removed for `{trigger}`.")) + + @wh_ar.command(name="list") + @requires_role(Role.HELPER) + async def wh_ar_list(self, ctx: commands.Context, name: str) -> None: + """List auto-responses.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + ars = nd.get("auto_responses", {}) + if not ars: + return await ctx.send(embed=info_embed("No auto-responses configured.")) + lines = [] + for trigger, cfg in ars.items(): + kind = "regex" if cfg.get("regex") else "text" + lines.append(f"• `{trigger}` ({kind}) → {truncate(cfg.get('reply', ''), 60)}") + await ctx.send(embed=info_embed("\n".join(lines), title=f"🤖 Auto-Responses — {name}")) + + # ── Scheduled messages ───────────────────────────────────────────────── + + @commands.group(name="wh-schedule", aliases=["whschedule"], invoke_without_command=True) + async def wh_schedule(self, ctx: commands.Context) -> None: + """Scheduled messages.""" + await ctx.send_help(ctx.command) + + @wh_schedule.command(name="add") + @requires_role(Role.ADMIN) + async def wh_schedule_add(self, ctx: commands.Context, name: str, minutes_from_now: int, *, content: str) -> None: + """Schedule a message to be sent in N minutes.""" + send_at = (datetime.now(timezone.utc) + timedelta(minutes=minutes_from_now)).isoformat() + async with self.config.networks() as ns: + ns[name].setdefault("scheduled_messages", []).append({ + "content": content, + "send_at_iso": send_at, + "creator": ctx.author.id, + }) + await ctx.send(embed=ok_embed(f"Message scheduled for {minutes_from_now} minutes from now.")) + + @wh_schedule.command(name="list") + @requires_role(Role.HELPER) + async def wh_schedule_list(self, ctx: commands.Context, name: str) -> None: + """List scheduled messages.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + msgs = nd.get("scheduled_messages", []) + if not msgs: + return await ctx.send(embed=info_embed("No scheduled messages.")) + lines = [] + for i, sm in enumerate(msgs, 1): + lines.append(f"**{i}.** {sm.get('send_at_iso', '?')[:16]} — {truncate(sm.get('content', ''), 60)}") + await ctx.send(embed=info_embed("\n".join(lines), title=f"📅 Scheduled — {name}")) + + @wh_schedule.command(name="clear") + @requires_role(Role.ADMIN) + async def wh_schedule_clear(self, ctx: commands.Context, name: str) -> None: + """Clear all scheduled messages.""" + async with self.config.networks() as ns: + ns[name]["scheduled_messages"] = [] + await ctx.send(embed=ok_embed("All scheduled messages cleared.")) + + # ── Analytics ────────────────────────────────────────────────────────── + + @commands.hybrid_command(name="wh-analytics") + @requires_role(Role.HELPER) + async def wh_analytics(self, ctx: commands.Context, name: str) -> None: + """View network analytics dashboard.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + analytics = nd.get("analytics", {}) + hourly = analytics.get("hourly", {}) + top_users = analytics.get("top_users", {}) + + em = discord.Embed(title=f"📈 Analytics — {name}", colour=COLOUR_INFO) + em.add_field(name="Total Messages", value=f"{nd.get('total_messages', 0):,}", inline=True) + em.add_field(name="Channels", value=str(len(nd.get("channels", []))), inline=True) + + # Last 24h + now = datetime.now(timezone.utc) + last_24h = sum(v for k, v in hourly.items() if k >= (now - timedelta(hours=24)).strftime("%Y-%m-%d-%H")) + last_7d = sum(hourly.values()) + em.add_field(name="Last 24h", value=f"{last_24h:,}", inline=True) + em.add_field(name="Last 7d", value=f"{last_7d:,}", inline=True) + + # Top users + if top_users: + sorted_users = sorted(top_users.items(), key=lambda x: x[1], reverse=True)[:5] + top_lines = [] + for uid_str, count in sorted_users: + user = self.bot.get_user(int(uid_str)) + top_lines.append(f"**{user or uid_str}** — {count:,}") + em.add_field(name="Top Users (all time)", value="\n".join(top_lines), inline=False) + + await ctx.send(embed=em) + + # ── Quiet hours ──────────────────────────────────────────────────────── + + @commands.hybrid_command(name="wh-quiet") + async def wh_quiet(self, ctx: commands.Context, name: str, start_hour: int, end_hour: int, tz_offset: int = 0) -> None: + """Set quiet hours — DM relay pauses during this window. + + Hours are 0-23. ``tz_offset`` is hours from UTC. + """ + if not (0 <= start_hour <= 23 and 0 <= end_hour <= 23): + return await ctx.send(embed=err_embed("Hours must be 0–23.")) + async with self.config.networks() as ns: + ns[name].setdefault("quiet_hours", {})[str(ctx.author.id)] = { + "start_hour": start_hour, + "end_hour": end_hour, + "tz_offset": tz_offset, + } + await ctx.send(embed=ok_embed(f"Quiet hours set: {start_hour}:00–{end_hour}:00 (UTC{tz_offset:+d})")) + + @commands.hybrid_command(name="wh-unquiet") + async def wh_unquiet(self, ctx: commands.Context, name: str) -> None: + """Remove your quiet hours.""" + async with self.config.networks() as ns: + ns[name].get("quiet_hours", {}).pop(str(ctx.author.id), None) + await ctx.send(embed=ok_embed("Quiet hours removed.")) + + # ── User colours ─────────────────────────────────────────────────────── + + @commands.hybrid_command(name="wh-colour", aliases=["wh-color"]) + async def wh_colour(self, ctx: commands.Context, name: str, hex_code: str = None) -> None: + """Set your name colour in embed mode (hex, e.g. ``#ff5733``).""" + if hex_code: + try: + int(hex_code.lstrip("#"), 16) + except ValueError: + return await ctx.send(embed=err_embed("Invalid hex colour.")) + async with self.config.networks() as ns: + if hex_code: + ns[name].setdefault("user_colours", {})[str(ctx.author.id)] = hex_code.lstrip("#") + else: + ns[name].get("user_colours", {}).pop(str(ctx.author.id), None) + await ctx.send(embed=ok_embed(f"Colour {'set' if hex_code else 'cleared'}.")) + + # ── Invites ──────────────────────────────────────────────────────────── + + @commands.group(name="wh-invite", aliases=["whinvite"], invoke_without_command=True) + async def wh_invite(self, ctx: commands.Context) -> None: + """Network invite codes.""" + await ctx.send_help(ctx.command) + + @wh_invite.command(name="create") + @requires_role(Role.ADMIN) + async def wh_invite_create(self, ctx: commands.Context, name: str, max_uses: int = 0) -> None: + """Create an invite code (``max_uses`` 0 = unlimited).""" + code = generate_invite_code() + async with self.config.networks() as ns: + ns[name].setdefault("invites", {})[code] = { + "creator": ctx.author.id, + "max_uses": max_uses, + "uses": 0, + "created_at": datetime.now(timezone.utc).isoformat(), + } + await ctx.send(embed=ok_embed(f"Invite code: `{code}`\nMax uses: {'unlimited' if max_uses == 0 else max_uses}")) + + @wh_invite.command(name="vanity") + @requires_role(Role.OWNER) + async def wh_invite_vanity(self, ctx: commands.Context, name: str, code: str) -> None: + """Set a vanity invite code.""" + if len(code) < 3 or len(code) > 20: + return await ctx.send(embed=err_embed("Code must be 3–20 characters.")) + async with self.config.networks() as ns: + ns[name]["vanity_invite"] = code + await ctx.send(embed=ok_embed(f"Vanity invite set: `{code}`")) + + @wh_invite.command(name="list") + @requires_role(Role.ADMIN) + async def wh_invite_list(self, ctx: commands.Context, name: str) -> None: + """List all invite codes.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + invites = nd.get("invites", {}) + if not invites: + return await ctx.send(embed=info_embed("No invites.")) + lines = [] + for code, data in invites.items(): + uses = data.get("uses", 0) + mx = data.get("max_uses", 0) + lines.append(f"`{code}` — {uses}/{mx if mx else '∞'} uses") + vanity = nd.get("vanity_invite") + if vanity: + lines.append(f"\n✨ Vanity: `{vanity}`") + await ctx.send(embed=info_embed("\n".join(lines), title=f"🔗 Invites — {name}")) + + @wh_invite.command(name="revoke") + @requires_role(Role.ADMIN) + async def wh_invite_revoke(self, ctx: commands.Context, name: str, code: str) -> None: + """Revoke an invite code.""" + async with self.config.networks() as ns: + ns[name].get("invites", {}).pop(code, None) + await ctx.send(embed=ok_embed(f"Invite `{code}` revoked.")) + + # ── Announcements ────────────────────────────────────────────────────── + + @commands.hybrid_command(name="wh-announce") + @requires_role(Role.ADMIN) + async def wh_announce(self, ctx: commands.Context, name: str, *, message: str) -> None: + """Broadcast an announcement to all channels in the network.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + em = announce_embed(message, title=f"📢 {name} — Announcement") + sent = 0 + for ch_id in nd.get("channels", []): + ch = self.bot.get_channel(ch_id) + if ch: + try: + await ch.send(embed=em) + sent += 1 + except Exception: + pass + await ctx.send(embed=ok_embed(f"Announcement sent to {sent} channels.")) + await self._audit(name, "announce", str(ctx.author), details=truncate(message, 100)) + + # ── Blackout schedules ───────────────────────────────────────────────── + + @commands.group(name="wh-blackout", aliases=["whblackout"], invoke_without_command=True) + async def wh_blackout(self, ctx: commands.Context) -> None: + """Scheduled freeze windows.""" + await ctx.send_help(ctx.command) + + @wh_blackout.command(name="add") + @requires_role(Role.ADMIN) + async def wh_blackout_add(self, ctx: commands.Context, name: str, start_hour: int, end_hour: int, *, days: str = "0,1,2,3,4,5,6") -> None: + """Add a blackout schedule. Days: 0=Mon … 6=Sun.""" + day_list = [int(d.strip()) for d in days.split(",") if d.strip().isdigit()] + async with self.config.networks() as ns: + ns[name].setdefault("blackout_schedules", []).append({ + "start_hour": start_hour, + "end_hour": end_hour, + "days": day_list, + }) + await ctx.send(embed=ok_embed(f"Blackout added: {start_hour}:00–{end_hour}:00 UTC on days {day_list}")) + + @wh_blackout.command(name="clear") + @requires_role(Role.ADMIN) + async def wh_blackout_clear(self, ctx: commands.Context, name: str) -> None: + """Clear all blackout schedules.""" + async with self.config.networks() as ns: + ns[name]["blackout_schedules"] = [] + await ctx.send(embed=ok_embed("All blackout schedules cleared.")) + + # ── Portal embed ─────────────────────────────────────────────────────── + + @commands.hybrid_command(name="wh-portal") + @requires_role(Role.ADMIN) + async def wh_portal(self, ctx: commands.Context, name: str) -> None: + """Post a self-updating portal embed in this channel.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + from ..utils import build_portal_embed + em = build_portal_embed(name, nd, len(nd.get("channels", [])), nd.get("total_messages", 0)) + msg = await ctx.send(embed=em) + async with self.config.networks() as ns: + ns[name].setdefault("portal_messages", {})[str(ctx.channel.id)] = msg.id + await ctx.send(embed=ok_embed("Portal embed posted. It will auto-update every 5 minutes."), delete_after=10) diff --git a/wormhole/commands/bridge.py b/wormhole/commands/bridge.py new file mode 100644 index 0000000..6cb8f09 --- /dev/null +++ b/wormhole/commands/bridge.py @@ -0,0 +1,111 @@ +"""Bridge commands — cross-network bridging and mirror channels.""" + +from __future__ import annotations + +import discord +from redbot.core import commands + +from ..models.permissions import Role, requires_role +from ..utils import ok_embed, err_embed, info_embed, COLOUR_INFO + + +class BridgeCommands: + """Mixin — network bridging and mirror channels.""" + + @commands.group(name="wh-bridge", aliases=["whbridge"], invoke_without_command=True) + async def wh_bridge(self, ctx: commands.Context) -> None: + """Cross-network bridging.""" + await ctx.send_help(ctx.command) + + @wh_bridge.command(name="link") + @requires_role(Role.OWNER) + async def wh_bridge_link(self, ctx: commands.Context, source: str, target: str) -> None: + """Bridge messages from one network to another (one-way).""" + nd_src = await self._net(source) + nd_tgt = await self._net(target) + if not nd_src: + return await ctx.send(embed=err_embed(f"Network `{source}` not found.")) + if not nd_tgt: + return await ctx.send(embed=err_embed(f"Network `{target}` not found.")) + async with self.config.networks() as ns: + bridge_to = ns[source].setdefault("bridge_to", []) + if target not in bridge_to: + bridge_to.append(target) + bridge_from = ns[target].setdefault("bridge_from", []) + if source not in bridge_from: + bridge_from.append(source) + await ctx.send(embed=ok_embed(f"Bridge: `{source}` → `{target}` (one-way).")) + await self._audit(source, "bridge_link", str(ctx.author), target) + + @wh_bridge.command(name="unlink") + @requires_role(Role.OWNER) + async def wh_bridge_unlink(self, ctx: commands.Context, source: str, target: str) -> None: + """Remove a bridge.""" + async with self.config.networks() as ns: + if source in ns: + bt = ns[source].get("bridge_to", []) + if target in bt: + bt.remove(target) + if target in ns: + bf = ns[target].get("bridge_from", []) + if source in bf: + bf.remove(source) + await ctx.send(embed=ok_embed(f"Bridge `{source}` → `{target}` removed.")) + await self._audit(source, "bridge_unlink", str(ctx.author), target) + + @wh_bridge.command(name="list") + async def wh_bridge_list(self, ctx: commands.Context, name: str) -> None: + """List bridges for a network.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + lines = [] + for t in nd.get("bridge_to", []): + lines.append(f"➡️ `{name}` → `{t}`") + for f in nd.get("bridge_from", []): + lines.append(f"⬅️ `{f}` → `{name}`") + if not lines: + lines.append("No bridges configured.") + await ctx.send(embed=info_embed("\n".join(lines), title=f"🌉 Bridges — {name}")) + + # ── Mirror channels ──────────────────────────────────────────────────── + + @commands.group(name="wh-mirror", aliases=["whmirror"], invoke_without_command=True) + async def wh_mirror(self, ctx: commands.Context) -> None: + """Mirror channels — exact copy of relay to an extra channel.""" + await ctx.send_help(ctx.command) + + @wh_mirror.command(name="add") + @requires_role(Role.ADMIN) + async def wh_mirror_add(self, ctx: commands.Context, name: str, channel: discord.TextChannel) -> None: + """Add a mirror channel.""" + async with self.config.networks() as ns: + mirrors = ns[name].setdefault("mirror_channels", []) + if channel.id not in mirrors: + mirrors.append(channel.id) + await ctx.send(embed=ok_embed(f"{channel.mention} added as mirror for `{name}`.")) + + @wh_mirror.command(name="remove", aliases=["rm"]) + @requires_role(Role.ADMIN) + async def wh_mirror_rm(self, ctx: commands.Context, name: str, channel: discord.TextChannel) -> None: + """Remove a mirror channel.""" + async with self.config.networks() as ns: + mirrors = ns[name].get("mirror_channels", []) + if channel.id in mirrors: + mirrors.remove(channel.id) + await ctx.send(embed=ok_embed(f"{channel.mention} removed from mirrors for `{name}`.")) + + @wh_mirror.command(name="list") + async def wh_mirror_list(self, ctx: commands.Context, name: str) -> None: + """List mirror channels.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + mirrors = nd.get("mirror_channels", []) + if not mirrors: + return await ctx.send(embed=info_embed("No mirror channels.")) + lines = [] + for ch_id in mirrors: + ch = self.bot.get_channel(ch_id) + lines.append(f"• {ch.mention if ch else ch_id}") + await ctx.send(embed=info_embed("\n".join(lines), title=f"🪞 Mirrors — {name}")) diff --git a/wormhole/commands/debug.py b/wormhole/commands/debug.py new file mode 100644 index 0000000..497016b --- /dev/null +++ b/wormhole/commands/debug.py @@ -0,0 +1,178 @@ +"""Debug and diagnostic commands — health, trace, test, backup/restore.""" + +from __future__ import annotations + +import json +from copy import deepcopy +from datetime import datetime, timezone +from typing import Optional + +import discord +from redbot.core import checks, commands + +from ..models.permissions import Role, has_role, requires_role +from ..utils import ok_embed, err_embed, info_embed, warn_embed, COLOUR_INFO, human_timedelta + + +class DebugCommands: + """Mixin — debug, diagnostics, backup/restore.""" + + @commands.group(name="wh-debug", aliases=["whdebug"], invoke_without_command=True) + async def wh_debug(self, ctx: commands.Context) -> None: + """Debug and diagnostic tools.""" + await ctx.send_help(ctx.command) + + @wh_debug.command(name="health") + @requires_role(Role.HELPER) + async def wh_debug_health(self, ctx: commands.Context, name: str) -> None: + """Check network health — permissions, webhooks, channel status.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + channels = nd.get("channels", []) + lines = [] + healthy = 0 + for ch_id in channels: + ch = self.bot.get_channel(ch_id) + if not ch: + lines.append(f"❌ `{ch_id}` — channel not found") + continue + perms = ch.permissions_for(ch.guild.me) + issues = [] + if not perms.send_messages: + issues.append("no send_messages") + if not perms.manage_webhooks: + issues.append("no manage_webhooks") + if not perms.embed_links: + issues.append("no embed_links") + if not perms.attach_files: + issues.append("no attach_files") + if not perms.read_message_history: + issues.append("no read_history") + if issues: + lines.append(f"⚠️ {ch.mention} — {', '.join(issues)}") + else: + lines.append(f"✅ {ch.mention}") + healthy += 1 + em = discord.Embed( + title=f"🏥 Health — {name}", + description="\n".join(lines) or "No channels", + colour=COLOUR_INFO, + ) + em.set_footer(text=f"{healthy}/{len(channels)} healthy · Last check: {nd.get('last_health_check', 'never')[:16]}") + await ctx.send(embed=em) + + @wh_debug.command(name="trace") + @requires_role(Role.ADMIN) + async def wh_debug_trace(self, ctx: commands.Context) -> None: + """Toggle trace mode for this channel — logs all relay decisions.""" + if ctx.channel.id in self._trace_channels: + self._trace_channels.discard(ctx.channel.id) + await ctx.send(embed=ok_embed("Trace mode **disabled** for this channel.")) + else: + self._trace_channels.add(ctx.channel.id) + await ctx.send(embed=ok_embed("Trace mode **enabled** — relay decisions will be logged here.")) + + @wh_debug.command(name="testsend") + @requires_role(Role.ADMIN) + async def wh_debug_testsend(self, ctx: commands.Context, name: str) -> None: + """Send a test message through the relay.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + em = info_embed( + f"🧪 Test relay from {ctx.author} in {ctx.guild.name if ctx.guild else 'DM'}", + title=f"Test — {name}", + ) + sent = 0 + for ch_id in nd.get("channels", []): + if ch_id == ctx.channel.id: + continue + ch = self.bot.get_channel(ch_id) + if ch: + try: + await ch.send(embed=em) + sent += 1 + except Exception as exc: + await ctx.send(embed=warn_embed(f"Failed to send to {ch.mention}: {exc}")) + await ctx.send(embed=ok_embed(f"Test message sent to {sent} channels.")) + + @wh_debug.command(name="relaydebug") + @requires_role(Role.ADMIN) + async def wh_debug_relaydebug(self, ctx: commands.Context, name: str) -> None: + """Show detailed relay pipeline state for diagnostics.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + em = discord.Embed(title=f"🔬 Relay Debug — {name}", colour=COLOUR_INFO) + em.add_field(name="Relay Mode", value=nd.get("relay_mode", "webhook"), inline=True) + em.add_field(name="Frozen", value=str(nd.get("frozen", False)), inline=True) + em.add_field(name="Silent", value=str(nd.get("silent", False)), inline=True) + em.add_field(name="Channels", value=str(len(nd.get("channels", []))), inline=True) + em.add_field(name="Banned Users", value=str(len(nd.get("banned_users", []))), inline=True) + em.add_field(name="Muted Users", value=str(len(nd.get("muted_users", []))), inline=True) + em.add_field(name="Msg Map Size", value=str(len(self.msg_map.forward.get(name, {}))), inline=True) + em.add_field(name="WH Cache Size", value=str(len(self._wh_cache)), inline=True) + am = nd.get("automod", {}) + active_am = [k.replace("anti_", "") for k, v in am.items() if k.startswith("anti_") and v] + em.add_field(name="Automod Active", value=", ".join(active_am) or "None", inline=False) + await ctx.send(embed=em) + + # ── Backup / Restore ─────────────────────────────────────────────────── + + @wh_debug.command(name="backup") + @requires_role(Role.OWNER) + async def wh_debug_backup(self, ctx: commands.Context, name: str) -> None: + """Export network config as a JSON file.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + data = deepcopy(nd) + # Sanitise sensitive bits + data.pop("audit_log", None) + data.pop("reports", None) + import io + buf = io.BytesIO(json.dumps(data, indent=2, default=str).encode()) + buf.seek(0) + await ctx.send( + embed=ok_embed(f"Backup for `{name}`:"), + file=discord.File(buf, filename=f"wormhole_{name}_backup.json"), + ) + + @wh_debug.command(name="restore") + @checks.is_owner() + async def wh_debug_restore(self, ctx: commands.Context, name: str) -> None: + """Restore a network from a JSON backup (attach file to message).""" + if not ctx.message.attachments: + return await ctx.send(embed=err_embed("Attach a backup JSON file.")) + att = ctx.message.attachments[0] + if not att.filename.endswith(".json"): + return await ctx.send(embed=err_embed("File must be .json")) + raw = await att.read() + try: + data = json.loads(raw) + except json.JSONDecodeError: + return await ctx.send(embed=err_embed("Invalid JSON.")) + async with self.config.networks() as ns: + ns[name] = data + await ctx.send(embed=ok_embed(f"Network `{name}` restored from backup.")) + + # ── Version ──────────────────────────────────────────────────────────── + + @commands.hybrid_command(name="wh-version") + async def wh_version(self, ctx: commands.Context) -> None: + """Show Wormhole cog version.""" + nets = await self.config.networks() + total_ch = sum(len(d.get("channels", [])) for d in nets.values()) + total_msg = sum(d.get("total_messages", 0) for d in nets.values()) + em = discord.Embed( + title="🌀 Wormhole", + description=( + f"**Version:** {self.__version__}\n" + f"**Networks:** {len(nets)}\n" + f"**Channels:** {total_ch}\n" + f"**Messages relayed:** {total_msg:,}" + ), + colour=COLOUR_INFO, + ) + await ctx.send(embed=em) diff --git a/wormhole/commands/dm.py b/wormhole/commands/dm.py new file mode 100644 index 0000000..2fbde3e --- /dev/null +++ b/wormhole/commands/dm.py @@ -0,0 +1,106 @@ +"""DM relay commands — subscribe, send, configure DM relay.""" + +from __future__ import annotations + +import discord +from redbot.core import commands + +from ..models.permissions import Role, requires_role +from ..utils import ok_embed, err_embed, info_embed, dm_embed, truncate + + +class DMCommands: + """Mixin — DM relay management.""" + + @commands.group(name="wh-dm", aliases=["whdm"], invoke_without_command=True) + async def wh_dm(self, ctx: commands.Context) -> None: + """DM relay — receive network messages in your DMs.""" + await ctx.send_help(ctx.command) + + @wh_dm.command(name="enable") + @requires_role(Role.ADMIN) + async def wh_dm_enable(self, ctx: commands.Context, name: str, enabled: bool = True) -> None: + """Enable or disable DM relay for a network.""" + async with self.config.networks() as ns: + ns[name]["dm_enabled"] = enabled + await ctx.send(embed=ok_embed(f"DM relay {'enabled' if enabled else 'disabled'} for `{name}`.")) + + @wh_dm.command(name="subscribe", aliases=["sub"]) + async def wh_dm_sub(self, ctx: commands.Context, name: str) -> None: + """Subscribe to receive network messages via DM.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + if not nd.get("dm_enabled"): + return await ctx.send(embed=err_embed(f"DM relay isn't enabled for `{name}`.")) + async with self.config.networks() as ns: + subs = ns[name].setdefault("dm_subscribers", []) + if ctx.author.id in subs: + return await ctx.send(embed=err_embed("You're already subscribed.")) + subs.append(ctx.author.id) + await ctx.send(embed=ok_embed(f"Subscribed to DM relay for `{name}`.")) + + @wh_dm.command(name="unsubscribe", aliases=["unsub"]) + async def wh_dm_unsub(self, ctx: commands.Context, name: str) -> None: + """Unsubscribe from DM relay.""" + async with self.config.networks() as ns: + subs = ns[name].get("dm_subscribers", []) + if ctx.author.id in subs: + subs.remove(ctx.author.id) + await ctx.send(embed=ok_embed(f"Unsubscribed from DM relay for `{name}`.")) + + @wh_dm.command(name="mode") + @requires_role(Role.ADMIN) + async def wh_dm_mode(self, ctx: commands.Context, name: str, mode: str) -> None: + """Set DM relay mode: ``embed``, ``compact``, or ``plain``.""" + if mode not in ("embed", "compact", "plain"): + return await ctx.send(embed=err_embed("Mode must be `embed`, `compact`, or `plain`.")) + async with self.config.networks() as ns: + ns[name]["dm_relay_mode"] = mode + await ctx.send(embed=ok_embed(f"DM relay mode set to **{mode}** for `{name}`.")) + + @wh_dm.command(name="send") + async def wh_dm_send(self, ctx: commands.Context, name: str, *, message: str) -> None: + """Send a message to the network from your DMs.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + if not nd.get("dm_enabled"): + return await ctx.send(embed=err_embed("DM relay isn't enabled for this network.")) + if ctx.author.id in nd.get("banned_users", []): + return await ctx.send(embed=err_embed("You're banned from this network.")) + if ctx.author.id in nd.get("muted_users", []): + return await ctx.send(embed=err_embed("You're muted in this network.")) + + from ..utils import build_dm_relay_embed + em = build_dm_relay_embed(ctx.author, message, name) + for ch_id in nd.get("channels", []): + ch = self.bot.get_channel(ch_id) + if ch: + try: + await ch.send(embed=em) + except Exception: + pass + await ctx.send(embed=ok_embed("Message sent to the network.")) + + @wh_dm.command(name="ignore") + async def wh_dm_ignore(self, ctx: commands.Context, name: str, user: discord.User) -> None: + """Ignore a user in DM relay (you won't see their messages).""" + async with self.config.networks() as ns: + ignores = ns[name].setdefault("user_ignores", {}) + uid_str = str(ctx.author.id) + user_ignores = ignores.setdefault(uid_str, []) + if user.id not in user_ignores: + user_ignores.append(user.id) + await ctx.send(embed=ok_embed(f"{user.display_name} ignored in DM relay for `{name}`.")) + + @wh_dm.command(name="unignore") + async def wh_dm_unignore(self, ctx: commands.Context, name: str, user: discord.User) -> None: + """Stop ignoring a user in DM relay.""" + async with self.config.networks() as ns: + ignores = ns[name].get("user_ignores", {}) + uid_str = str(ctx.author.id) + user_ignores = ignores.get(uid_str, []) + if user.id in user_ignores: + user_ignores.remove(user.id) + await ctx.send(embed=ok_embed(f"{user.display_name} unignored in DM relay for `{name}`.")) diff --git a/wormhole/commands/filters.py b/wormhole/commands/filters.py new file mode 100644 index 0000000..430e8aa --- /dev/null +++ b/wormhole/commands/filters.py @@ -0,0 +1,188 @@ +"""Filter commands — word/regex filters, automod configuration, attachment filters.""" + +from __future__ import annotations + +import discord +from redbot.core import commands + +from ..models.permissions import Role, requires_role +from ..utils import ok_embed, err_embed, info_embed, COLOUR_INFO + + +class FilterCommands: + """Mixin — content filters and automod configuration.""" + + @commands.group(name="wh-filter", aliases=["whfilter"], invoke_without_command=True) + async def wh_filter(self, ctx: commands.Context) -> None: + """Manage content filters and automod.""" + await ctx.send_help(ctx.command) + + # ── Word filters ─────────────────────────────────────────────────────── + + @wh_filter.command(name="addword") + @requires_role(Role.MODERATOR) + async def wh_filter_addword(self, ctx: commands.Context, name: str, *, word: str) -> None: + """Add a word to the filter list.""" + async with self.config.networks() as ns: + wf = ns[name].setdefault("word_filters", []) + if word.lower() not in [w.lower() for w in wf]: + wf.append(word) + await ctx.send(embed=ok_embed(f"Word `{word}` added to filters for `{name}`.")) + await self._audit(name, "filter_add", str(ctx.author), word) + + @wh_filter.command(name="rmword") + @requires_role(Role.MODERATOR) + async def wh_filter_rmword(self, ctx: commands.Context, name: str, *, word: str) -> None: + """Remove a word from the filter list.""" + async with self.config.networks() as ns: + wf = ns[name].get("word_filters", []) + ns[name]["word_filters"] = [w for w in wf if w.lower() != word.lower()] + await ctx.send(embed=ok_embed(f"Word `{word}` removed from filters for `{name}`.")) + + @wh_filter.command(name="addregex") + @requires_role(Role.ADMIN) + async def wh_filter_addregex(self, ctx: commands.Context, name: str, *, pattern: str) -> None: + """Add a regex pattern to the filter list.""" + import re + try: + re.compile(pattern) + except re.error as e: + return await ctx.send(embed=err_embed(f"Invalid regex: {e}")) + async with self.config.networks() as ns: + rf = ns[name].setdefault("regex_filters", []) + if pattern not in rf: + rf.append(pattern) + await ctx.send(embed=ok_embed(f"Regex `{pattern}` added to filters for `{name}`.")) + await self._audit(name, "regex_add", str(ctx.author), pattern) + + @wh_filter.command(name="rmregex") + @requires_role(Role.ADMIN) + async def wh_filter_rmregex(self, ctx: commands.Context, name: str, *, pattern: str) -> None: + """Remove a regex pattern from the filter list.""" + async with self.config.networks() as ns: + rf = ns[name].get("regex_filters", []) + if pattern in rf: + rf.remove(pattern) + await ctx.send(embed=ok_embed(f"Regex removed from filters for `{name}`.")) + + @wh_filter.command(name="list") + @requires_role(Role.HELPER) + async def wh_filter_list(self, ctx: commands.Context, name: str) -> None: + """List all active filters.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + words = nd.get("word_filters", []) + regexes = nd.get("regex_filters", []) + lines = [] + if words: + lines.append("**Word Filters:**") + for w in words: + lines.append(f" • `{w}`") + if regexes: + lines.append("**Regex Filters:**") + for r in regexes: + lines.append(f" • `{r}`") + if not lines: + lines.append("No filters configured.") + await ctx.send(embed=info_embed("\n".join(lines), title=f"🔍 Filters — {name}")) + + # ── Automod ──────────────────────────────────────────────────────────── + + @wh_filter.command(name="automod") + @requires_role(Role.ADMIN) + async def wh_filter_automod(self, ctx: commands.Context, name: str, feature: str, enabled: bool) -> None: + """Toggle automod features. + + Features: ``spam``, ``invite``, ``link``, ``caps``, ``mention_spam``, + ``zalgo``, ``spoiler``, ``emote_spam``, ``newline_spam``, ``raid`` + """ + key_map = { + "spam": "anti_spam", "invite": "anti_invite", + "link": "anti_link", "caps": "anti_caps", + "mention_spam": "anti_mention_spam", "zalgo": "anti_zalgo", + "spoiler": "anti_spoiler", "emote_spam": "anti_emote_spam", + "newline_spam": "anti_newline_spam", "raid": "anti_raid", + } + if feature not in key_map: + return await ctx.send(embed=err_embed(f"Unknown feature. Options: {', '.join(key_map)}")) + async with self.config.networks() as ns: + am = ns[name].setdefault("automod", {}) + am[key_map[feature]] = enabled + am["enabled"] = any(v for k, v in am.items() if k.startswith("anti_")) + await ctx.send(embed=ok_embed(f"Automod `{feature}` {'enabled' if enabled else 'disabled'} for `{name}`.")) + await self._audit(name, f"automod_{feature}", str(ctx.author), details=str(enabled)) + + # Update detectors + if feature == "spam": + if enabled: + nd = await self._net(name) + am = nd.get("automod", {}) + from ..utils import DuplicateDetector + self.dup_detectors[name] = DuplicateDetector(am.get("spam_window", 30.0), am.get("spam_threshold", 3)) + else: + self.dup_detectors.pop(name, None) + elif feature == "raid": + if enabled: + nd = await self._net(name) + am = nd.get("automod", {}) + from ..utils import RaidDetector + self.raid_detectors[name] = RaidDetector(am.get("raid_window", 60.0), am.get("raid_threshold", 10)) + else: + self.raid_detectors.pop(name, None) + + @wh_filter.command(name="automod-set") + @requires_role(Role.ADMIN) + async def wh_filter_automod_set(self, ctx: commands.Context, name: str, key: str, value: str) -> None: + """Set an automod parameter. + + Keys: ``max_mentions``, ``caps_threshold``, ``spam_window``, ``spam_threshold``, + ``max_emotes``, ``max_newlines``, ``raid_window``, ``raid_threshold`` + """ + valid_keys = { + "max_mentions", "caps_threshold", "spam_window", "spam_threshold", + "max_emotes", "max_newlines", "raid_window", "raid_threshold", + } + if key not in valid_keys: + return await ctx.send(embed=err_embed(f"Unknown key. Options: {', '.join(valid_keys)}")) + try: + parsed = float(value) if "." in value else int(value) + except ValueError: + return await ctx.send(embed=err_embed("Value must be a number.")) + async with self.config.networks() as ns: + ns[name].setdefault("automod", {})[key] = parsed + await ctx.send(embed=ok_embed(f"Automod `{key}` set to `{parsed}` for `{name}`.")) + + # ── Attachment filters ───────────────────────────────────────────────── + + @wh_filter.command(name="blockext") + @requires_role(Role.ADMIN) + async def wh_filter_blockext(self, ctx: commands.Context, name: str, ext: str) -> None: + """Block a file extension (e.g. ``.exe``).""" + if not ext.startswith("."): + ext = "." + ext + async with self.config.networks() as ns: + exts = ns[name].setdefault("blocked_extensions", []) + if ext not in exts: + exts.append(ext) + await ctx.send(embed=ok_embed(f"Extension `{ext}` blocked for `{name}`.")) + + @wh_filter.command(name="unblockext") + @requires_role(Role.ADMIN) + async def wh_filter_unblockext(self, ctx: commands.Context, name: str, ext: str) -> None: + """Unblock a file extension.""" + if not ext.startswith("."): + ext = "." + ext + async with self.config.networks() as ns: + exts = ns[name].get("blocked_extensions", []) + if ext in exts: + exts.remove(ext) + await ctx.send(embed=ok_embed(f"Extension `{ext}` unblocked for `{name}`.")) + + @wh_filter.command(name="maxfilesize") + @requires_role(Role.ADMIN) + async def wh_filter_maxfilesize(self, ctx: commands.Context, name: str, mb: float = 0) -> None: + """Set max file size in MB (0 to disable).""" + async with self.config.networks() as ns: + ns[name]["max_filesize"] = int(mb * 1024 * 1024) if mb > 0 else None + await ctx.send(embed=ok_embed(f"Max file size {'set to ' + str(mb) + ' MB' if mb > 0 else 'disabled'} for `{name}`.")) diff --git a/wormhole/commands/mentions.py b/wormhole/commands/mentions.py new file mode 100644 index 0000000..1fdb623 --- /dev/null +++ b/wormhole/commands/mentions.py @@ -0,0 +1,112 @@ +"""Mention policy commands — granular per-network, per-server, per-user controls.""" + +from __future__ import annotations + +import discord +from redbot.core import commands + +from ..models.permissions import Role, requires_role +from ..utils import ok_embed, err_embed, info_embed, COLOUR_INFO + + +class MentionCommands: + """Mixin — mention policy management.""" + + @commands.group(name="wh-mention", aliases=["whmention"], invoke_without_command=True) + async def wh_mention(self, ctx: commands.Context) -> None: + """Configure mention behaviour.""" + await ctx.send_help(ctx.command) + + @wh_mention.command(name="set") + @requires_role(Role.ADMIN) + async def wh_mention_set(self, ctx: commands.Context, name: str, kind: str, allowed: bool) -> None: + """Set a mention policy. + + Kinds: ``user``, ``role``, ``everyone``, ``here`` + """ + key_map = { + "user": "allow_user_mentions", + "role": "allow_role_mentions", + "everyone": "allow_everyone", + "here": "allow_here", + } + if kind not in key_map: + return await ctx.send(embed=err_embed(f"Unknown kind. Options: {', '.join(key_map)}")) + async with self.config.networks() as ns: + policy = ns[name].setdefault("mention_policy", {}) + policy[key_map[kind]] = allowed + await ctx.send(embed=ok_embed(f"`{kind}` mentions {'allowed' if allowed else 'blocked'} in `{name}`.")) + await self._audit(name, f"mention_{kind}", str(ctx.author), details=str(allowed)) + + @wh_mention.command(name="exempt") + @requires_role(Role.ADMIN) + async def wh_mention_exempt(self, ctx: commands.Context, name: str, user: discord.User) -> None: + """Exempt a user from mention restrictions.""" + async with self.config.networks() as ns: + exempt = ns[name].setdefault("mention_exempt_users", []) + if user.id not in exempt: + exempt.append(user.id) + await ctx.send(embed=ok_embed(f"{user.mention} exempted from mention restrictions in `{name}`.")) + + @wh_mention.command(name="unexempt") + @requires_role(Role.ADMIN) + async def wh_mention_unexempt(self, ctx: commands.Context, name: str, user: discord.User) -> None: + """Remove a user's mention exemption.""" + async with self.config.networks() as ns: + exempt = ns[name].get("mention_exempt_users", []) + if user.id in exempt: + exempt.remove(user.id) + await ctx.send(embed=ok_embed(f"{user.mention} exemption removed in `{name}`.")) + + @wh_mention.command(name="optout") + async def wh_mention_optout(self, ctx: commands.Context, name: str) -> None: + """Opt out of being pinged via relay.""" + async with self.config.networks() as ns: + optout = ns[name].setdefault("mention_optout_users", []) + if ctx.author.id not in optout: + optout.append(ctx.author.id) + await ctx.send(embed=ok_embed("You've opted out of relay pings.")) + + @wh_mention.command(name="optin") + async def wh_mention_optin(self, ctx: commands.Context, name: str) -> None: + """Opt back in to relay pings.""" + async with self.config.networks() as ns: + optout = ns[name].get("mention_optout_users", []) + if ctx.author.id in optout: + optout.remove(ctx.author.id) + await ctx.send(embed=ok_embed("You've opted back in to relay pings.")) + + @wh_mention.command(name="serveroverride") + @requires_role(Role.ADMIN) + async def wh_mention_serveroverride(self, ctx: commands.Context, name: str, guild_id: int, kind: str, allowed: bool) -> None: + """Set per-server mention override.""" + key_map = { + "user": "allow_user_mentions", + "role": "allow_role_mentions", + "everyone": "allow_everyone", + "here": "allow_here", + } + if kind not in key_map: + return await ctx.send(embed=err_embed(f"Unknown kind. Options: {', '.join(key_map)}")) + async with self.config.networks() as ns: + so = ns[name].setdefault("server_mention_overrides", {}) + gid_str = str(guild_id) + so.setdefault(gid_str, {})[key_map[kind]] = allowed + await ctx.send(embed=ok_embed(f"Server override set for `{guild_id}` in `{name}`.")) + + @wh_mention.command(name="show") + @requires_role(Role.HELPER) + async def wh_mention_show(self, ctx: commands.Context, name: str) -> None: + """Show current mention policy.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + policy = nd.get("mention_policy", {}) + em = discord.Embed(title=f"📢 Mention Policy — {name}", colour=COLOUR_INFO) + em.add_field(name="User mentions", value="✅" if policy.get("allow_user_mentions") else "❌", inline=True) + em.add_field(name="Role mentions", value="✅" if policy.get("allow_role_mentions") else "❌", inline=True) + em.add_field(name="@everyone", value="✅" if policy.get("allow_everyone") else "❌", inline=True) + em.add_field(name="@here", value="✅" if policy.get("allow_here") else "❌", inline=True) + em.add_field(name="Exempt users", value=str(len(nd.get("mention_exempt_users", []))), inline=True) + em.add_field(name="Opted out", value=str(len(nd.get("mention_optout_users", []))), inline=True) + await ctx.send(embed=em) diff --git a/wormhole/commands/moderation.py b/wormhole/commands/moderation.py new file mode 100644 index 0000000..c201246 --- /dev/null +++ b/wormhole/commands/moderation.py @@ -0,0 +1,308 @@ +"""Moderation commands — ban, unban, mute, unmute, purge, mod edit/delete, warnings.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +import discord +from redbot.core import commands + +from ..models.permissions import Role, has_role, requires_role, role_name, get_role +from ..utils import ok_embed, err_embed, info_embed, warn_embed, truncate, COLOUR_INFO + + +class ModerationCommands: + """Mixin — moderation tools (per-network, role-gated).""" + + @commands.group(name="wh-mod", aliases=["whmod"], invoke_without_command=True) + async def wh_mod(self, ctx: commands.Context) -> None: + """Network moderation tools.""" + await ctx.send_help(ctx.command) + + # ── User bans ────────────────────────────────────────────────────────── + + @wh_mod.command(name="ban") + @requires_role(Role.MODERATOR) + async def wh_mod_ban(self, ctx: commands.Context, name: str, user: discord.User, *, reason: str = "No reason") -> None: + """Ban a user from the network.""" + nd = await self._net(name) + if user.id == nd["owner_id"]: + return await ctx.send(embed=err_embed("You can't ban the network owner.")) + target_role = get_role(nd, user.id) + actor_role = get_role(nd, ctx.author.id) + if target_role >= actor_role and not await self.bot.is_owner(ctx.author): + return await ctx.send(embed=err_embed(f"You can't ban a {role_name(target_role)} — your role: {role_name(actor_role)}")) + async with self.config.networks() as ns: + bans = ns[name].setdefault("banned_users", []) + if user.id not in bans: + bans.append(user.id) + await ctx.send(embed=ok_embed(f"{user.mention} banned from `{name}`.\nReason: {reason}")) + await self._audit(name, "ban", str(ctx.author), str(user), reason) + await self._log(nd, warn_embed(f"🔨 **{user}** banned by {ctx.author}\nReason: {reason}")) + + @wh_mod.command(name="unban") + @requires_role(Role.MODERATOR) + async def wh_mod_unban(self, ctx: commands.Context, name: str, user: discord.User) -> None: + """Unban a user from the network.""" + async with self.config.networks() as ns: + bans = ns[name].get("banned_users", []) + if user.id in bans: + bans.remove(user.id) + await ctx.send(embed=ok_embed(f"{user.mention} unbanned from `{name}`.")) + await self._audit(name, "unban", str(ctx.author), str(user)) + + # ── User mutes ───────────────────────────────────────────────────────── + + @wh_mod.command(name="mute") + @requires_role(Role.MODERATOR) + async def wh_mod_mute(self, ctx: commands.Context, name: str, user: discord.User, *, reason: str = "No reason") -> None: + """Mute a user in the network (messages won't relay).""" + nd = await self._net(name) + target_role = get_role(nd, user.id) + actor_role = get_role(nd, ctx.author.id) + if target_role >= actor_role and not await self.bot.is_owner(ctx.author): + return await ctx.send(embed=err_embed(f"You can't mute a {role_name(target_role)}.")) + async with self.config.networks() as ns: + mutes = ns[name].setdefault("muted_users", []) + if user.id not in mutes: + mutes.append(user.id) + await ctx.send(embed=ok_embed(f"{user.mention} muted in `{name}`.\nReason: {reason}")) + await self._audit(name, "mute", str(ctx.author), str(user), reason) + await self._log(nd, warn_embed(f"🔇 **{user}** muted by {ctx.author}\nReason: {reason}")) + + @wh_mod.command(name="unmute") + @requires_role(Role.MODERATOR) + async def wh_mod_unmute(self, ctx: commands.Context, name: str, user: discord.User) -> None: + """Unmute a user in the network.""" + async with self.config.networks() as ns: + mutes = ns[name].get("muted_users", []) + if user.id in mutes: + mutes.remove(user.id) + await ctx.send(embed=ok_embed(f"{user.mention} unmuted in `{name}`.")) + await self._audit(name, "unmute", str(ctx.author), str(user)) + + # ── Server bans/mutes ────────────────────────────────────────────────── + + @wh_mod.command(name="banserver") + @requires_role(Role.ADMIN) + async def wh_mod_banserver(self, ctx: commands.Context, name: str, guild_id: int) -> None: + """Ban a server from the network.""" + async with self.config.networks() as ns: + bans = ns[name].setdefault("banned_servers", []) + if guild_id not in bans: + bans.append(guild_id) + await ctx.send(embed=ok_embed(f"Server `{guild_id}` banned from `{name}`.")) + await self._audit(name, "banserver", str(ctx.author), str(guild_id)) + + @wh_mod.command(name="unbanserver") + @requires_role(Role.ADMIN) + async def wh_mod_unbanserver(self, ctx: commands.Context, name: str, guild_id: int) -> None: + """Unban a server from the network.""" + async with self.config.networks() as ns: + bans = ns[name].get("banned_servers", []) + if guild_id in bans: + bans.remove(guild_id) + await ctx.send(embed=ok_embed(f"Server `{guild_id}` unbanned from `{name}`.")) + await self._audit(name, "unbanserver", str(ctx.author), str(guild_id)) + + @wh_mod.command(name="muteserver") + @requires_role(Role.ADMIN) + async def wh_mod_muteserver(self, ctx: commands.Context, name: str, guild_id: int) -> None: + """Mute a server in the network.""" + async with self.config.networks() as ns: + mutes = ns[name].setdefault("muted_servers", []) + if guild_id not in mutes: + mutes.append(guild_id) + await ctx.send(embed=ok_embed(f"Server `{guild_id}` muted in `{name}`.")) + await self._audit(name, "muteserver", str(ctx.author), str(guild_id)) + + @wh_mod.command(name="unmuteserver") + @requires_role(Role.ADMIN) + async def wh_mod_unmuteserver(self, ctx: commands.Context, name: str, guild_id: int) -> None: + """Unmute a server in the network.""" + async with self.config.networks() as ns: + mutes = ns[name].get("muted_servers", []) + if guild_id in mutes: + mutes.remove(guild_id) + await ctx.send(embed=ok_embed(f"Server `{guild_id}` unmuted in `{name}`.")) + await self._audit(name, "unmuteserver", str(ctx.author), str(guild_id)) + + # ── Allowlist ────────────────────────────────────────────────────────── + + @wh_mod.command(name="allowserver") + @requires_role(Role.ADMIN) + async def wh_mod_allowserver(self, ctx: commands.Context, name: str, guild_id: int) -> None: + """Add a server to the allowlist.""" + async with self.config.networks() as ns: + allow = ns[name].setdefault("allowlist_servers", []) + if guild_id not in allow: + allow.append(guild_id) + await ctx.send(embed=ok_embed(f"Server `{guild_id}` added to allowlist for `{name}`.")) + + @wh_mod.command(name="removeallow") + @requires_role(Role.ADMIN) + async def wh_mod_removeallow(self, ctx: commands.Context, name: str, guild_id: int) -> None: + """Remove a server from the allowlist.""" + async with self.config.networks() as ns: + allow = ns[name].get("allowlist_servers", []) + if guild_id in allow: + allow.remove(guild_id) + await ctx.send(embed=ok_embed(f"Server `{guild_id}` removed from allowlist for `{name}`.")) + + # ── Purge ────────────────────────────────────────────────────────────── + + @wh_mod.command(name="purge") + @requires_role(Role.MODERATOR) + async def wh_mod_purge(self, ctx: commands.Context, name: str, count: int = 10) -> None: + """Purge recent relayed messages across all channels (max 50).""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + count = min(count, 50) + deleted = 0 + for ch_id in nd.get("channels", []): + ch = self.bot.get_channel(ch_id) + if not ch: + continue + try: + async for msg in ch.history(limit=count): + if msg.author.id == self.bot.user.id or (msg.webhook_id is not None): + try: + await msg.delete() + deleted += 1 + except Exception: + pass + except Exception: + pass + await ctx.send(embed=ok_embed(f"Purged {deleted} relayed messages across `{name}`.")) + await self._audit(name, "purge", str(ctx.author), details=f"count={count}") + + # ── Cross-network mod edit/delete ────────────────────────────────────── + + @wh_mod.command(name="edit") + @requires_role(Role.MODERATOR) + async def wh_mod_edit(self, ctx: commands.Context, name: str, message_id: int, *, new_content: str) -> None: + """Edit a relayed message across all channels.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + orig_id = self.msg_map.get_original(name, message_id) or message_id + relayed = self.msg_map.get_relayed(name, orig_id) + edited = 0 + for ch_id, msg_id in relayed.items(): + ch = self.bot.get_channel(ch_id) + if not ch: + continue + try: + wh = await self._wh(ch) + await wh.edit_message(msg_id, content=new_content) + edited += 1 + except Exception: + try: + msg = await ch.fetch_message(msg_id) + if msg.author.id == self.bot.user.id: + await msg.edit(content=new_content) + edited += 1 + except Exception: + pass + await ctx.send(embed=ok_embed(f"Edited {edited} relayed copies.")) + await self._audit(name, "mod_edit", str(ctx.author), str(orig_id), truncate(new_content, 100)) + + @wh_mod.command(name="nuke") + @requires_role(Role.MODERATOR) + async def wh_mod_nuke(self, ctx: commands.Context, name: str, message_id: int) -> None: + """Delete a message and all its relayed copies across the network.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + orig_id = self.msg_map.get_original(name, message_id) or message_id + relayed = self.msg_map.get_relayed(name, orig_id) + deleted = 0 + for ch_id, msg_id in relayed.items(): + ch = self.bot.get_channel(ch_id) + if ch: + try: + msg = await ch.fetch_message(msg_id) + await msg.delete() + deleted += 1 + except Exception: + pass + # Also try to delete in source channels + for ch_id in nd.get("channels", []): + ch = self.bot.get_channel(ch_id) + if ch: + try: + msg = await ch.fetch_message(orig_id) + await msg.delete() + deleted += 1 + except Exception: + pass + await ctx.send(embed=ok_embed(f"Nuked {deleted} message(s) across `{name}`.")) + await self._audit(name, "nuke", str(ctx.author), str(orig_id)) + + # ── Audit log ────────────────────────────────────────────────────────── + + @wh_mod.command(name="audit") + @requires_role(Role.HELPER) + async def wh_mod_audit(self, ctx: commands.Context, name: str, count: int = 20) -> None: + """View the network audit log.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + entries = nd.get("audit_log", [])[-count:] + if not entries: + return await ctx.send(embed=info_embed("Audit log is empty.")) + lines = [] + for e in reversed(entries): + ts = e.get("timestamp", "?")[:16] + act = e.get("action", "?") + usr = e.get("user", "?") + tgt = e.get("target", "") + det = e.get("details", "") + line = f"`{ts}` **{act}** by {usr}" + if tgt: + line += f" → {tgt}" + if det: + line += f" ({det})" + lines.append(line) + em = discord.Embed( + title=f"📋 Audit Log — {name}", + description="\n".join(lines), + colour=COLOUR_INFO, + ) + await ctx.send(embed=em) + + # ── Ban/mute lists ───────────────────────────────────────────────────── + + @wh_mod.command(name="bans") + @requires_role(Role.HELPER) + async def wh_mod_bans(self, ctx: commands.Context, name: str) -> None: + """List banned users.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + bans = nd.get("banned_users", []) + if not bans: + return await ctx.send(embed=info_embed(f"No banned users in `{name}`.")) + lines = [] + for uid in bans: + user = self.bot.get_user(uid) + lines.append(f"• {user or uid}") + await ctx.send(embed=info_embed("\n".join(lines), title=f"🔨 Banned Users — {name}")) + + @wh_mod.command(name="mutes") + @requires_role(Role.HELPER) + async def wh_mod_mutes(self, ctx: commands.Context, name: str) -> None: + """List muted users.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + mutes = nd.get("muted_users", []) + if not mutes: + return await ctx.send(embed=info_embed(f"No muted users in `{name}`.")) + lines = [] + for uid in mutes: + user = self.bot.get_user(uid) + lines.append(f"• {user or uid}") + await ctx.send(embed=info_embed("\n".join(lines), title=f"🔇 Muted Users — {name}")) diff --git a/wormhole/commands/network.py b/wormhole/commands/network.py new file mode 100644 index 0000000..405b643 --- /dev/null +++ b/wormhole/commands/network.py @@ -0,0 +1,167 @@ +"""Network lifecycle commands — create, delete, open, close, list, discover, info.""" + +from __future__ import annotations + +from copy import deepcopy +from datetime import datetime, timezone +from typing import Optional + +import discord +from redbot.core import commands + +from ..models.config import DEFAULT_NETWORK +from ..models.permissions import Role, has_role, requires_role, role_name, get_role, list_staff +from ..utils import ok_embed, err_embed, info_embed, warn_embed, COLOUR_INFO, COLOUR_OK, truncate + + +class NetworkCommands: + """Mixin — network lifecycle commands.""" + + @commands.hybrid_command(name="wh-create") + async def wh_create(self, ctx: commands.Context, name: str, *, description: str = "") -> None: + """Create a new wormhole network.""" + nets = await self.config.networks() + if name in nets: + return await ctx.send(embed=err_embed(f"Network `{name}` already exists.")) + mx = await self.config.max_networks_per_user() + if sum(1 for n in nets.values() if n["owner_id"] == ctx.author.id) >= mx: + return await ctx.send(embed=err_embed(f"You've hit the limit of {mx} networks.")) + d = deepcopy(DEFAULT_NETWORK) + d["owner_id"] = ctx.author.id + d["description"] = description + d["created_at"] = datetime.now(timezone.utc).isoformat() + async with self.config.networks() as ns: + ns[name] = d + from ..utils import CooldownBucket + self.cooldowns[name] = CooldownBucket(d["rate_limit_rate"], d["rate_limit_per"]) + await ctx.send(embed=ok_embed( + f"Network `{name}` created!\n" + f"Open channels with `wh open {name}` in target channels." + )) + await self._audit(name, "create", str(ctx.author)) + + @commands.hybrid_command(name="wh-delete") + async def wh_delete(self, ctx: commands.Context, name: str) -> None: + """Delete a network (owner or bot owner only).""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + if not await self.bot.is_owner(ctx.author): + if not has_role(nd, ctx.author.id, Role.OWNER): + return await ctx.send(embed=err_embed("Only the network owner or bot owner can delete a network.")) + async with self.config.networks() as ns: + ns.pop(name, None) + self.cooldowns.pop(name, None) + await ctx.send(embed=ok_embed(f"Network `{name}` deleted.")) + + @commands.hybrid_command(name="wh-open") + @requires_role(Role.ADMIN) + async def wh_open(self, ctx: commands.Context, name: str) -> None: + """Add the current channel to a network.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + if ctx.channel.id in nd.get("channels", []): + return await ctx.send(embed=err_embed("This channel is already in the network.")) + # Check not in another network + existing = await self._net_for_ch(ctx.channel.id) + if existing: + return await ctx.send(embed=err_embed(f"This channel is already in network `{existing}`.")) + async with self.config.networks() as ns: + ns[name].setdefault("channels", []).append(ctx.channel.id) + await ctx.send(embed=ok_embed(f"Channel linked to `{name}`. Messages will now relay.")) + await self._audit(name, "open", str(ctx.author), str(ctx.channel.id)) + + @commands.hybrid_command(name="wh-close") + async def wh_close(self, ctx: commands.Context, name: str = None) -> None: + """Remove the current channel from a network.""" + if not name: + name = await self._net_for_ch(ctx.channel.id) + if not name: + return await ctx.send(embed=err_embed("This channel isn't in any network.")) + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + if not has_role(nd, ctx.author.id, Role.ADMIN) and not await self.bot.is_owner(ctx.author): + return await ctx.send(embed=err_embed("You need Admin role or higher.")) + if ctx.channel.id not in nd.get("channels", []): + return await ctx.send(embed=err_embed("This channel isn't in that network.")) + async with self.config.networks() as ns: + ns[name]["channels"].remove(ctx.channel.id) + await ctx.send(embed=ok_embed(f"Channel removed from `{name}`.")) + await self._audit(name, "close", str(ctx.author), str(ctx.channel.id)) + + @commands.hybrid_command(name="wh-list") + async def wh_list(self, ctx: commands.Context) -> None: + """List all networks you have access to.""" + nets = await self.config.networks() + if not nets: + return await ctx.send(embed=info_embed("No networks exist yet.")) + lines = [] + for n, d in sorted(nets.items()): + status = "❄️" if d.get("frozen") else "🟢" + ch_count = len(d.get("channels", [])) + role = get_role(d, ctx.author.id) + role_tag = f" [{role_name(role)}]" if role > Role.MEMBER else "" + lines.append(f"{status} **{n}** — {ch_count} channels, {d.get('total_messages', 0):,} msgs{role_tag}") + em = discord.Embed(title="🌀 Wormhole Networks", description="\n".join(lines), colour=COLOUR_INFO) + await ctx.send(embed=em) + + @commands.hybrid_command(name="wh-discover") + async def wh_discover(self, ctx: commands.Context) -> None: + """Browse public networks.""" + nets = await self.config.networks() + public = [(n, d) for n, d in nets.items() if d.get("public")] + if not public: + return await ctx.send(embed=info_embed("No public networks available.")) + lines = [] + for n, d in public: + desc = truncate(d.get("description", ""), 60) or "No description" + tags = ", ".join(d.get("tags", [])) or "—" + lines.append(f"**{n}** — {desc}\n Tags: {tags} · {len(d.get('channels', []))} ch") + em = discord.Embed(title="🔍 Public Networks", description="\n".join(lines), colour=COLOUR_INFO) + await ctx.send(embed=em) + + @commands.hybrid_command(name="wh-info") + async def wh_info(self, ctx: commands.Context, name: str) -> None: + """Show detailed info about a network.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + + colour = discord.Colour(nd["colour"]) if nd.get("colour") else COLOUR_INFO + status = "❄️ Frozen" if nd.get("frozen") else "🟢 Active" + owner = self.bot.get_user(nd["owner_id"]) + + em = discord.Embed(title=f"🌀 {name}", description=nd.get("description") or "No description", colour=colour) + em.add_field(name="Status", value=status, inline=True) + em.add_field(name="Owner", value=str(owner or nd["owner_id"]), inline=True) + + # Staff count by role + staff = list_staff(nd) + staff_summary = [] + for role in (Role.ADMIN, Role.MODERATOR, Role.HELPER): + count = sum(1 for r in staff.values() if r == role) + if count: + staff_summary.append(f"{role_name(role)}: {count}") + em.add_field(name="Staff", value="\n".join(staff_summary) if staff_summary else "None", inline=True) + + em.add_field(name="Channels", value=str(len(nd.get("channels", []))), inline=True) + em.add_field(name="Messages", value=f"{nd.get('total_messages', 0):,}", inline=True) + em.add_field(name="Relay Mode", value=nd.get("relay_mode", "webhook"), inline=True) + + features = [] + for key, label in [ + ("sync_edits", "Edit sync"), ("sync_deletes", "Delete sync"), + ("sync_reactions", "Reaction sync"), ("sync_replies", "Reply sync"), + ("dm_enabled", "DM relay"), ("anonymous", "Anonymous"), + ("rules_required", "ToS gate"), ("media_only", "Media-only"), + ]: + if nd.get(key): + features.append(f"✅ {label}") + if features: + em.add_field(name="Features", value=" · ".join(features), inline=False) + + your_role = get_role(nd, ctx.author.id) + em.set_footer(text=f"Your role: {role_name(your_role)} · Created {(nd.get('created_at') or '?')[:10]}") + await ctx.send(embed=em) diff --git a/wormhole/commands/reports.py b/wormhole/commands/reports.py new file mode 100644 index 0000000..3896553 --- /dev/null +++ b/wormhole/commands/reports.py @@ -0,0 +1,138 @@ +"""Report system commands — create, view, resolve, dismiss reports.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import discord +from redbot.core import commands + +from ..models.permissions import Role, requires_role, has_role +from ..utils import ok_embed, err_embed, info_embed, warn_embed, truncate, COLOUR_INFO + + +class ReportCommands: + """Mixin — user report system (per-network).""" + + @commands.group(name="wh-report", aliases=["whreport"], invoke_without_command=True) + async def wh_report(self, ctx: commands.Context) -> None: + """Network report system.""" + await ctx.send_help(ctx.command) + + @wh_report.command(name="msg") + async def wh_report_msg(self, ctx: commands.Context, message_id: int, *, reason: str = "No reason provided") -> None: + """Report a message by ID.""" + net_name = await self._net_for_ch(ctx.channel.id) + if not net_name: + return await ctx.send(embed=err_embed("This channel isn't in a network.")) + nd = await self._net(net_name) + report_id = nd.get("report_counter", 0) + 1 + report = { + "id": report_id, + "reporter_id": ctx.author.id, + "message_id": message_id, + "channel_id": ctx.channel.id, + "guild_id": ctx.guild.id if ctx.guild else None, + "reason": reason, + "timestamp": datetime.now(timezone.utc).isoformat(), + "resolved": False, + "resolved_by": None, + "resolution": None, + } + async with self.config.networks() as ns: + ns[net_name].setdefault("reports", []).append(report) + ns[net_name]["report_counter"] = report_id + await ctx.send(embed=ok_embed(f"Report #{report_id} submitted. Staff will review it."), delete_after=10) + await self._log(nd, warn_embed( + f"🚩 **Report #{report_id}**\n" + f"Reporter: {ctx.author} (`{ctx.author.id}`)\n" + f"Message ID: `{message_id}`\n" + f"Reason: {reason}", + title="New Report", + )) + + @wh_report.command(name="list") + @requires_role(Role.HELPER) + async def wh_report_list(self, ctx: commands.Context, name: str, show_resolved: bool = False) -> None: + """List reports for a network.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + reports = nd.get("reports", []) + if not show_resolved: + reports = [r for r in reports if not r.get("resolved")] + if not reports: + return await ctx.send(embed=info_embed("No open reports.")) + lines = [] + for r in reports[-20:]: + status = "✅" if r.get("resolved") else "🔴" + reporter = self.bot.get_user(r.get("reporter_id")) + lines.append( + f"{status} **#{r['id']}** — by {reporter or r.get('reporter_id')}\n" + f" Reason: {truncate(r.get('reason', ''), 80)}" + ) + em = discord.Embed(title=f"🚩 Reports — {name}", description="\n".join(lines), colour=COLOUR_INFO) + await ctx.send(embed=em) + + @wh_report.command(name="view") + @requires_role(Role.HELPER) + async def wh_report_view(self, ctx: commands.Context, name: str, report_id: int) -> None: + """View details of a specific report.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + report = None + for r in nd.get("reports", []): + if r.get("id") == report_id: + report = r + break + if not report: + return await ctx.send(embed=err_embed(f"Report #{report_id} not found.")) + reporter = self.bot.get_user(report.get("reporter_id")) + em = discord.Embed(title=f"🚩 Report #{report_id}", colour=COLOUR_INFO) + em.add_field(name="Reporter", value=str(reporter or report.get("reporter_id")), inline=True) + em.add_field(name="Status", value="Resolved" if report.get("resolved") else "Open", inline=True) + em.add_field(name="Reason", value=report.get("reason", "N/A"), inline=False) + em.add_field(name="Timestamp", value=report.get("timestamp", "?")[:16], inline=True) + if report.get("message_id"): + em.add_field(name="Message ID", value=str(report["message_id"]), inline=True) + if report.get("resolved_by"): + resolver = self.bot.get_user(report["resolved_by"]) + em.add_field(name="Resolved By", value=str(resolver or report["resolved_by"]), inline=True) + if report.get("resolution"): + em.add_field(name="Resolution", value=report["resolution"], inline=False) + await ctx.send(embed=em) + + @wh_report.command(name="resolve") + @requires_role(Role.MODERATOR) + async def wh_report_resolve(self, ctx: commands.Context, name: str, report_id: int, *, resolution: str = "Resolved") -> None: + """Resolve a report.""" + async with self.config.networks() as ns: + for r in ns[name].get("reports", []): + if r.get("id") == report_id: + r["resolved"] = True + r["resolved_by"] = ctx.author.id + r["resolution"] = resolution + r["resolved_at"] = datetime.now(timezone.utc).isoformat() + break + else: + return await ctx.send(embed=err_embed(f"Report #{report_id} not found.")) + await ctx.send(embed=ok_embed(f"Report #{report_id} resolved.")) + await self._audit(name, "report_resolve", str(ctx.author), str(report_id), resolution) + + @wh_report.command(name="dismiss") + @requires_role(Role.MODERATOR) + async def wh_report_dismiss(self, ctx: commands.Context, name: str, report_id: int, *, reason: str = "Dismissed") -> None: + """Dismiss a report.""" + async with self.config.networks() as ns: + for r in ns[name].get("reports", []): + if r.get("id") == report_id: + r["resolved"] = True + r["resolved_by"] = ctx.author.id + r["resolution"] = f"Dismissed: {reason}" + r["resolved_at"] = datetime.now(timezone.utc).isoformat() + break + else: + return await ctx.send(embed=err_embed(f"Report #{report_id} not found.")) + await ctx.send(embed=ok_embed(f"Report #{report_id} dismissed.")) + await self._audit(name, "report_dismiss", str(ctx.author), str(report_id), reason) diff --git a/wormhole/commands/settings.py b/wormhole/commands/settings.py new file mode 100644 index 0000000..dc661f7 --- /dev/null +++ b/wormhole/commands/settings.py @@ -0,0 +1,356 @@ +"""Settings commands — all `wh set` sub-commands for network configuration.""" + +from __future__ import annotations + +from typing import Optional + +import discord +from redbot.core import commands + +from ..models.permissions import Role, requires_role +from ..utils import ok_embed, err_embed, info_embed, COLOUR_INFO, COLOUR_OK + + +class SettingsCommands: + """Mixin — network settings management.""" + + @commands.group(name="wh-set", aliases=["whset"], invoke_without_command=True) + async def wh_set(self, ctx: commands.Context) -> None: + """Configure network settings.""" + await ctx.send_help(ctx.command) + + # ── Relay mode ───────────────────────────────────────────────────────── + + @wh_set.command(name="relaymode") + @requires_role(Role.ADMIN) + async def wh_set_relaymode(self, ctx: commands.Context, name: str, mode: str) -> None: + """Set relay mode: ``webhook``, ``embed``, or ``compact``.""" + if mode not in ("webhook", "embed", "compact"): + return await ctx.send(embed=err_embed("Mode must be `webhook`, `embed`, or `compact`.")) + async with self.config.networks() as ns: + ns[name]["relay_mode"] = mode + await ctx.send(embed=ok_embed(f"Relay mode for `{name}` set to **{mode}**.")) + await self._audit(name, "set_relaymode", str(ctx.author), details=mode) + + # ── Webhooks ─────────────────────────────────────────────────────────── + + @wh_set.command(name="webhooks") + @requires_role(Role.ADMIN) + async def wh_set_webhooks(self, ctx: commands.Context, name: str, enabled: bool) -> None: + """Enable or disable webhook relay.""" + async with self.config.networks() as ns: + ns[name]["use_webhooks"] = enabled + await ctx.send(embed=ok_embed(f"Webhooks {'enabled' if enabled else 'disabled'} for `{name}`.")) + await self._audit(name, "set_webhooks", str(ctx.author), details=str(enabled)) + + # ── Sync toggles ────────────────────────────────────────────────────── + + @wh_set.command(name="sync") + @requires_role(Role.ADMIN) + async def wh_set_sync(self, ctx: commands.Context, name: str, feature: str, enabled: bool) -> None: + """Toggle sync features: ``edits``, ``deletes``, ``reactions``, ``replies``, ``stickers``, ``pins``, ``threads``, ``typing``.""" + key_map = { + "edits": "sync_edits", "deletes": "sync_deletes", + "reactions": "sync_reactions", "replies": "sync_replies", + "stickers": "sync_stickers", "pins": "sync_pins", + "threads": "sync_threads", "typing": "sync_typing", + } + if feature not in key_map: + return await ctx.send(embed=err_embed(f"Unknown feature. Options: {', '.join(key_map)}")) + async with self.config.networks() as ns: + ns[name][key_map[feature]] = enabled + await ctx.send(embed=ok_embed(f"`{feature}` sync {'enabled' if enabled else 'disabled'} for `{name}`.")) + await self._audit(name, f"set_{feature}", str(ctx.author), details=str(enabled)) + + # ── Identity ─────────────────────────────────────────────────────────── + + @wh_set.command(name="imagemode") + @requires_role(Role.ADMIN) + async def wh_set_imagemode(self, ctx: commands.Context, name: str, mode: str) -> None: + """Set avatar mode: ``user``, ``server``, ``custom``.""" + if mode not in ("user", "server", "custom"): + return await ctx.send(embed=err_embed("Mode must be `user`, `server`, or `custom`.")) + async with self.config.networks() as ns: + ns[name]["image_mode"] = mode + await ctx.send(embed=ok_embed(f"Image mode for `{name}` set to **{mode}**.")) + + @wh_set.command(name="namemode") + @requires_role(Role.ADMIN) + async def wh_set_namemode(self, ctx: commands.Context, name: str, mode: str) -> None: + """Set name mode: ``user``, ``server``, ``both``, ``custom``.""" + if mode not in ("user", "server", "both", "custom"): + return await ctx.send(embed=err_embed("Mode must be `user`, `server`, `both`, or `custom`.")) + async with self.config.networks() as ns: + ns[name]["name_mode"] = mode + await ctx.send(embed=ok_embed(f"Name mode for `{name}` set to **{mode}**.")) + + @wh_set.command(name="icon") + @requires_role(Role.ADMIN) + async def wh_set_icon(self, ctx: commands.Context, name: str, url: str = None) -> None: + """Set a custom network icon URL.""" + async with self.config.networks() as ns: + ns[name]["custom_icon"] = url + await ctx.send(embed=ok_embed(f"Custom icon {'set' if url else 'cleared'} for `{name}`.")) + + @wh_set.command(name="customname") + @requires_role(Role.ADMIN) + async def wh_set_customname(self, ctx: commands.Context, name: str, *, value: str = None) -> None: + """Set a custom display name for relayed messages.""" + async with self.config.networks() as ns: + ns[name]["custom_name"] = value + await ctx.send(embed=ok_embed(f"Custom name {'set' if value else 'cleared'} for `{name}`.")) + + @wh_set.command(name="colour", aliases=["color"]) + @requires_role(Role.ADMIN) + async def wh_set_colour(self, ctx: commands.Context, name: str, hex_code: str = None) -> None: + """Set the network embed colour (hex, e.g. ``#ff5733``).""" + if hex_code: + try: + colour = int(hex_code.lstrip("#"), 16) + except ValueError: + return await ctx.send(embed=err_embed("Invalid hex colour.")) + else: + colour = None + async with self.config.networks() as ns: + ns[name]["colour"] = colour + await ctx.send(embed=ok_embed(f"Colour {'set' if colour else 'cleared'} for `{name}`.")) + + # ── Description / MOTD / rules ───────────────────────────────────────── + + @wh_set.command(name="description", aliases=["desc"]) + @requires_role(Role.ADMIN) + async def wh_set_desc(self, ctx: commands.Context, name: str, *, text: str) -> None: + """Set the network description.""" + async with self.config.networks() as ns: + ns[name]["description"] = text + await ctx.send(embed=ok_embed(f"Description updated for `{name}`.")) + + @wh_set.command(name="motd") + @requires_role(Role.ADMIN) + async def wh_set_motd(self, ctx: commands.Context, name: str, *, text: str = "") -> None: + """Set or clear the Message of the Day.""" + async with self.config.networks() as ns: + ns[name]["motd"] = text + await ctx.send(embed=ok_embed(f"MOTD {'set' if text else 'cleared'} for `{name}`.")) + + @wh_set.command(name="welcome") + @requires_role(Role.ADMIN) + async def wh_set_welcome(self, ctx: commands.Context, name: str, *, text: str = "") -> None: + """Set or clear the welcome message for new servers.""" + async with self.config.networks() as ns: + ns[name]["welcome_message"] = text + await ctx.send(embed=ok_embed(f"Welcome message {'set' if text else 'cleared'} for `{name}`.")) + + # ── Network behaviour ────────────────────────────────────────────────── + + @wh_set.command(name="freeze") + @requires_role(Role.ADMIN) + async def wh_set_freeze(self, ctx: commands.Context, name: str) -> None: + """Freeze the network — relay paused.""" + async with self.config.networks() as ns: + ns[name]["frozen"] = True + await ctx.send(embed=ok_embed(f"`{name}` is now ❄️ frozen. Messages won't relay.")) + await self._audit(name, "freeze", str(ctx.author)) + + @wh_set.command(name="unfreeze") + @requires_role(Role.ADMIN) + async def wh_set_unfreeze(self, ctx: commands.Context, name: str) -> None: + """Unfreeze the network — resume relay.""" + async with self.config.networks() as ns: + ns[name]["frozen"] = False + await ctx.send(embed=ok_embed(f"`{name}` is now 🟢 active.")) + await self._audit(name, "unfreeze", str(ctx.author)) + + @wh_set.command(name="anonymous", aliases=["anon"]) + @requires_role(Role.ADMIN) + async def wh_set_anonymous(self, ctx: commands.Context, name: str, enabled: bool) -> None: + """Toggle anonymous mode.""" + import secrets + async with self.config.networks() as ns: + ns[name]["anonymous"] = enabled + if enabled and not ns[name].get("anon_salt"): + ns[name]["anon_salt"] = secrets.token_hex(8) + await ctx.send(embed=ok_embed(f"Anonymous mode {'enabled' if enabled else 'disabled'} for `{name}`.")) + + @wh_set.command(name="nsfw") + @requires_role(Role.ADMIN) + async def wh_set_nsfw(self, ctx: commands.Context, name: str, gate: bool) -> None: + """Toggle NSFW gate (only relay to NSFW channels if source is NSFW).""" + async with self.config.networks() as ns: + ns[name]["nsfw_gate"] = gate + await ctx.send(embed=ok_embed(f"NSFW gate {'enabled' if gate else 'disabled'} for `{name}`.")) + + @wh_set.command(name="mediaonly") + @requires_role(Role.ADMIN) + async def wh_set_mediaonly(self, ctx: commands.Context, name: str, enabled: bool) -> None: + """Toggle media-only mode (only relay messages with attachments).""" + async with self.config.networks() as ns: + ns[name]["media_only"] = enabled + await ctx.send(embed=ok_embed(f"Media-only mode {'enabled' if enabled else 'disabled'} for `{name}`.")) + + @wh_set.command(name="silent") + @requires_role(Role.ADMIN) + async def wh_set_silent(self, ctx: commands.Context, name: str, enabled: bool) -> None: + """Toggle silent mode (suppress status messages).""" + async with self.config.networks() as ns: + ns[name]["silent"] = enabled + await ctx.send(embed=ok_embed(f"Silent mode {'enabled' if enabled else 'disabled'} for `{name}`.")) + + @wh_set.command(name="embeds") + @requires_role(Role.ADMIN) + async def wh_set_embeds(self, ctx: commands.Context, name: str, enabled: bool) -> None: + """Toggle forwarding embeds from relayed messages.""" + async with self.config.networks() as ns: + ns[name]["forward_embeds"] = enabled + await ctx.send(embed=ok_embed(f"Embed forwarding {'enabled' if enabled else 'disabled'} for `{name}`.")) + + # ── Rate limiting ────────────────────────────────────────────────────── + + @wh_set.command(name="ratelimit") + @requires_role(Role.ADMIN) + async def wh_set_ratelimit(self, ctx: commands.Context, name: str, rate: int = 5, per: float = 10.0) -> None: + """Set rate limit: *rate* messages per *per* seconds.""" + async with self.config.networks() as ns: + ns[name]["rate_limit_rate"] = rate + ns[name]["rate_limit_per"] = per + if name in self.cooldowns: + self.cooldowns[name].update(rate, per) + else: + from ..utils import CooldownBucket + self.cooldowns[name] = CooldownBucket(rate, per) + await ctx.send(embed=ok_embed(f"Rate limit: {rate} msgs per {per}s for `{name}`.")) + + @wh_set.command(name="slowmode") + @requires_role(Role.ADMIN) + async def wh_set_slowmode(self, ctx: commands.Context, name: str, seconds: int = 0) -> None: + """Set slowmode (seconds between messages per user, 0 to disable).""" + async with self.config.networks() as ns: + ns[name]["slowmode"] = max(0, seconds) + await ctx.send(embed=ok_embed(f"Slowmode set to {seconds}s for `{name}`.")) + + # ── Log channel ──────────────────────────────────────────────────────── + + @wh_set.command(name="logchannel", aliases=["log"]) + @requires_role(Role.ADMIN) + async def wh_set_logchannel(self, ctx: commands.Context, name: str, channel: discord.TextChannel = None) -> None: + """Set (or clear) the mod log channel.""" + async with self.config.networks() as ns: + ns[name]["log_channel"] = channel.id if channel else None + await ctx.send(embed=ok_embed(f"Log channel {'set to ' + channel.mention if channel else 'cleared'} for `{name}`.")) + + # ── Server nicknames ─────────────────────────────────────────────────── + + @wh_set.command(name="servernick") + @requires_role(Role.ADMIN) + async def wh_set_servernick(self, ctx: commands.Context, name: str, guild_id: int, *, nick: str = None) -> None: + """Set or clear a nickname for a server in relay messages.""" + async with self.config.networks() as ns: + nicks = ns[name].setdefault("server_nicknames", {}) + if nick: + nicks[str(guild_id)] = nick + else: + nicks.pop(str(guild_id), None) + await ctx.send(embed=ok_embed(f"Server nickname {'set' if nick else 'cleared'}.")) + + # ── Relay delay ──────────────────────────────────────────────────────── + + @wh_set.command(name="delay") + @requires_role(Role.ADMIN) + async def wh_set_delay(self, ctx: commands.Context, name: str, seconds: int = 0) -> None: + """Set relay delay (seconds before messages are relayed, 0 to disable).""" + async with self.config.networks() as ns: + ns[name]["relay_delay"] = max(0, seconds) + await ctx.send(embed=ok_embed(f"Relay delay set to {seconds}s for `{name}`.")) + + # ── Ephemeral auto-delete ────────────────────────────────────────────── + + @wh_set.command(name="ephemeral") + @requires_role(Role.ADMIN) + async def wh_set_ephemeral(self, ctx: commands.Context, name: str, seconds: int = 0) -> None: + """Auto-delete relayed messages after N seconds (0 to disable).""" + async with self.config.networks() as ns: + ns[name]["ephemeral_delay"] = max(0, seconds) + await ctx.send(embed=ok_embed(f"Ephemeral delay set to {seconds}s for `{name}`.")) + + # ── Discovery ────────────────────────────────────────────────────────── + + @wh_set.command(name="public") + @requires_role(Role.OWNER) + async def wh_set_public(self, ctx: commands.Context, name: str, enabled: bool) -> None: + """Make network publicly discoverable.""" + async with self.config.networks() as ns: + ns[name]["public"] = enabled + await ctx.send(embed=ok_embed(f"`{name}` is now {'public' if enabled else 'private'}.")) + + @wh_set.command(name="tags") + @requires_role(Role.ADMIN) + async def wh_set_tags(self, ctx: commands.Context, name: str, *, tags: str) -> None: + """Set discovery tags (comma-separated).""" + tag_list = [t.strip() for t in tags.split(",") if t.strip()] + async with self.config.networks() as ns: + ns[name]["tags"] = tag_list + await ctx.send(embed=ok_embed(f"Tags set for `{name}`: {', '.join(tag_list)}")) + + # ── Channel overrides ────────────────────────────────────────────────── + + @wh_set.command(name="override") + @requires_role(Role.ADMIN) + async def wh_set_override(self, ctx: commands.Context, name: str, key: str, value: str) -> None: + """Set a per-channel config override for the current channel.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + if ctx.channel.id not in nd.get("channels", []): + return await ctx.send(embed=err_embed("This channel isn't in the network.")) + # Try to parse value + parsed = value + if value.lower() in ("true", "false"): + parsed = value.lower() == "true" + else: + try: + parsed = int(value) + except ValueError: + try: + parsed = float(value) + except ValueError: + pass + async with self.config.networks() as ns: + overrides = ns[name].setdefault("channel_overrides", {}) + overrides.setdefault(str(ctx.channel.id), {})[key] = parsed + await ctx.send(embed=ok_embed(f"Override `{key} = {parsed}` set for this channel in `{name}`.")) + + # ── View all settings ────────────────────────────────────────────────── + + @wh_set.command(name="show") + @requires_role(Role.HELPER) + async def wh_set_show(self, ctx: commands.Context, name: str) -> None: + """Show current settings for a network.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + em = discord.Embed(title=f"⚙️ Settings — {name}", colour=COLOUR_INFO) + em.add_field(name="Relay", value=( + f"Mode: {nd.get('relay_mode', 'webhook')}\n" + f"Image: {nd.get('image_mode', 'user')}\n" + f"Name: {nd.get('name_mode', 'both')}\n" + f"Frozen: {nd.get('frozen', False)}\n" + f"Anonymous: {nd.get('anonymous', False)}" + ), inline=True) + em.add_field(name="Sync", value=( + f"Edits: {nd.get('sync_edits')}\n" + f"Deletes: {nd.get('sync_deletes')}\n" + f"Reactions: {nd.get('sync_reactions')}\n" + f"Replies: {nd.get('sync_replies')}\n" + f"Typing: {nd.get('sync_typing', False)}" + ), inline=True) + em.add_field(name="Limits", value=( + f"Rate: {nd.get('rate_limit_rate', 5)}/{nd.get('rate_limit_per', 10)}s\n" + f"Slowmode: {nd.get('slowmode', 0)}s\n" + f"Delay: {nd.get('relay_delay', 0)}s\n" + f"Ephemeral: {nd.get('ephemeral_delay', 0)}s" + ), inline=True) + am = nd.get("automod", {}) + if am.get("enabled"): + active = [k.replace("anti_", "") for k, v in am.items() if k.startswith("anti_") and v] + em.add_field(name="Automod", value=", ".join(active) or "None active", inline=False) + await ctx.send(embed=em) diff --git a/wormhole/commands/social.py b/wormhole/commands/social.py new file mode 100644 index 0000000..b8f48ab --- /dev/null +++ b/wormhole/commands/social.py @@ -0,0 +1,189 @@ +"""Social commands — karma, starboard, highlights, bookmarks, profiles.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +import discord +from redbot.core import commands + +from ..models.permissions import Role, has_role, requires_role, get_role, role_name +from ..utils import ok_embed, err_embed, info_embed, star_embed, warn_embed, truncate, COLOUR_INFO, COLOUR_STAR + + +class SocialCommands: + """Mixin — social features.""" + + # ── Profiles ─────────────────────────────────────────────────────────── + + @commands.hybrid_command(name="wh-profile") + async def wh_profile(self, ctx: commands.Context, name: str, user: discord.User = None) -> None: + """View a user's wormhole profile in a network.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + target = user or ctx.author + p = nd.get("user_profiles", {}).get(str(target.id)) + if not p: + return await ctx.send(embed=info_embed(f"{target.display_name} hasn't been seen in `{name}`.")) + + role = get_role(nd, target.id) + karma = nd.get("karma_scores", {}).get(str(target.id), 0) + + em = discord.Embed( + title=f"👤 {target.display_name}", + colour=COLOUR_INFO, + ) + em.set_thumbnail(url=target.display_avatar.url) + em.add_field(name="Role", value=role_name(role), inline=True) + em.add_field(name="Messages", value=f"{p.get('message_count', 0):,}", inline=True) + em.add_field(name="Karma", value=str(karma), inline=True) + em.add_field(name="First Seen", value=p.get("first_seen", "Unknown")[:10], inline=True) + em.add_field(name="Servers", value=str(len(p.get("servers", []))), inline=True) + em.set_footer(text=f"Network: {name}") + await ctx.send(embed=em) + + # ── Karma ────────────────────────────────────────────────────────────── + + @commands.group(name="wh-karma", aliases=["whkarma"], invoke_without_command=True) + async def wh_karma(self, ctx: commands.Context) -> None: + """Karma system commands.""" + await ctx.send_help(ctx.command) + + @wh_karma.command(name="enable") + @requires_role(Role.ADMIN) + async def wh_karma_enable(self, ctx: commands.Context, name: str, enabled: bool = True) -> None: + """Enable or disable the karma system.""" + async with self.config.networks() as ns: + ns[name]["karma_enabled"] = enabled + await ctx.send(embed=ok_embed(f"Karma {'enabled' if enabled else 'disabled'} for `{name}`.")) + + @wh_karma.command(name="emoji") + @requires_role(Role.ADMIN) + async def wh_karma_emoji(self, ctx: commands.Context, name: str, emoji: str = "👍") -> None: + """Set the karma reaction emoji.""" + async with self.config.networks() as ns: + ns[name]["karma_emoji"] = emoji + await ctx.send(embed=ok_embed(f"Karma emoji set to {emoji} for `{name}`.")) + + @wh_karma.command(name="top") + async def wh_karma_top(self, ctx: commands.Context, name: str, count: int = 10) -> None: + """Show karma leaderboard.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + scores = nd.get("karma_scores", {}) + if not scores: + return await ctx.send(embed=info_embed("No karma data yet.")) + sorted_scores = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:count] + lines = [] + medals = ["🥇", "🥈", "🥉"] + for i, (uid_str, score) in enumerate(sorted_scores): + user = self.bot.get_user(int(uid_str)) + medal = medals[i] if i < 3 else f"**{i+1}.**" + lines.append(f"{medal} {user or uid_str} — **{score}** karma") + em = discord.Embed(title=f"🏆 Karma Leaderboard — {name}", description="\n".join(lines), colour=COLOUR_INFO) + await ctx.send(embed=em) + + # ── Starboard ────────────────────────────────────────────────────────── + + @commands.group(name="wh-star", aliases=["whstar"], invoke_without_command=True) + async def wh_star(self, ctx: commands.Context) -> None: + """Starboard commands.""" + await ctx.send_help(ctx.command) + + @wh_star.command(name="enable") + @requires_role(Role.ADMIN) + async def wh_star_enable(self, ctx: commands.Context, name: str, enabled: bool = True) -> None: + """Enable or disable starboard.""" + async with self.config.networks() as ns: + ns[name]["starboard_enabled"] = enabled + await ctx.send(embed=ok_embed(f"Starboard {'enabled' if enabled else 'disabled'} for `{name}`.")) + + @wh_star.command(name="channel") + @requires_role(Role.ADMIN) + async def wh_star_channel(self, ctx: commands.Context, name: str, channel: discord.TextChannel = None) -> None: + """Set the starboard channel.""" + async with self.config.networks() as ns: + ns[name]["starboard_channel"] = channel.id if channel else None + await ctx.send(embed=ok_embed(f"Starboard channel {'set' if channel else 'cleared'} for `{name}`.")) + + @wh_star.command(name="threshold") + @requires_role(Role.ADMIN) + async def wh_star_threshold(self, ctx: commands.Context, name: str, threshold: int = 3) -> None: + """Set minimum stars to reach the starboard.""" + async with self.config.networks() as ns: + ns[name]["starboard_threshold"] = max(1, threshold) + await ctx.send(embed=ok_embed(f"Starboard threshold set to {threshold} for `{name}`.")) + + # ── Highlights ───────────────────────────────────────────────────────── + + @commands.group(name="wh-hl", aliases=["whhl"], invoke_without_command=True) + async def wh_hl(self, ctx: commands.Context) -> None: + """Keyword highlight notifications.""" + await ctx.send_help(ctx.command) + + @wh_hl.command(name="add") + async def wh_hl_add(self, ctx: commands.Context, name: str, *, keyword: str) -> None: + """Add a keyword to highlight.""" + async with self.config.networks() as ns: + hl = ns[name].setdefault("highlights", {}) + uid_str = str(ctx.author.id) + user_hl = hl.setdefault(uid_str, []) + if keyword.lower() in [k.lower() for k in user_hl]: + return await ctx.send(embed=err_embed("Keyword already highlighted.")) + if len(user_hl) >= 25: + return await ctx.send(embed=err_embed("Max 25 highlights per user.")) + user_hl.append(keyword) + await ctx.send(embed=ok_embed(f"Highlight `{keyword}` added for `{name}`.")) + + @wh_hl.command(name="remove", aliases=["rm"]) + async def wh_hl_remove(self, ctx: commands.Context, name: str, *, keyword: str) -> None: + """Remove a highlight keyword.""" + async with self.config.networks() as ns: + hl = ns[name].get("highlights", {}) + user_hl = hl.get(str(ctx.author.id), []) + ns[name]["highlights"][str(ctx.author.id)] = [k for k in user_hl if k.lower() != keyword.lower()] + await ctx.send(embed=ok_embed(f"Highlight `{keyword}` removed.")) + + @wh_hl.command(name="list") + async def wh_hl_list(self, ctx: commands.Context, name: str) -> None: + """List your highlights.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + user_hl = nd.get("highlights", {}).get(str(ctx.author.id), []) + if not user_hl: + return await ctx.send(embed=info_embed("No highlights set.")) + await ctx.send(embed=info_embed("\n".join(f"• `{k}`" for k in user_hl), title="🔔 Your Highlights")) + + # ── Bookmarks ────────────────────────────────────────────────────────── + + @commands.group(name="wh-bm", aliases=["whbm"], invoke_without_command=True) + async def wh_bm(self, ctx: commands.Context) -> None: + """Message bookmarks.""" + await ctx.send_help(ctx.command) + + @wh_bm.command(name="list") + async def wh_bm_list(self, ctx: commands.Context) -> None: + """List your bookmarks.""" + bm = await self.config.bookmarks() + user_bm = bm.get(str(ctx.author.id), []) + if not user_bm: + return await ctx.send(embed=info_embed("No bookmarks yet. Use the context menu to bookmark!")) + lines = [] + for b in reversed(user_bm[-15:]): + lines.append( + f"• [{b.get('timestamp', '?')[:10]}] **{b.get('author', '?')}** in {b.get('server', '?')}\n" + f" {truncate(b.get('content', ''), 80)} — [Jump]({b.get('jump_url', '#')})" + ) + em = discord.Embed(title="📌 Bookmarks", description="\n".join(lines), colour=COLOUR_INFO) + await ctx.send(embed=em) + + @wh_bm.command(name="clear") + async def wh_bm_clear(self, ctx: commands.Context) -> None: + """Clear all your bookmarks.""" + async with self.config.bookmarks() as bm: + bm.pop(str(ctx.author.id), None) + await ctx.send(embed=ok_embed("All bookmarks cleared.")) diff --git a/wormhole/commands/staff.py b/wormhole/commands/staff.py new file mode 100644 index 0000000..5986c72 --- /dev/null +++ b/wormhole/commands/staff.py @@ -0,0 +1,194 @@ +"""Staff management commands — assign roles, list staff, transfer ownership.""" + +from __future__ import annotations + +import discord +from redbot.core import commands + +from ..models.permissions import ( + Role, + can_assign, + get_role, + has_role, + list_staff, + requires_role, + role_from_str, + role_name, + set_staff_role, + remove_staff, +) +from ..utils import ok_embed, err_embed, info_embed, COLOUR_INFO + + +class StaffCommands: + """Mixin — staff management (per-network hierarchy).""" + + @commands.group(name="wh-staff", aliases=["whstaff"], invoke_without_command=True) + async def wh_staff(self, ctx: commands.Context) -> None: + """Manage network staff — add, remove, list, promote, demote.""" + await ctx.send_help(ctx.command) + + @wh_staff.command(name="add") + async def wh_staff_add(self, ctx: commands.Context, name: str, user: discord.User, role_str: str = "moderator") -> None: + """Assign a staff role to a user. + + Roles: ``admin``, ``moderator``, ``helper`` + """ + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + + target_role = role_from_str(role_str) + if target_role is None or target_role <= Role.MEMBER or target_role >= Role.OWNER: + return await ctx.send(embed=err_embed("Valid roles: `admin`, `moderator`, `helper`.")) + + actor_role = get_role(nd, ctx.author.id) + is_bot_owner = await self.bot.is_owner(ctx.author) + if not is_bot_owner and not can_assign(actor_role, target_role): + return await ctx.send(embed=err_embed( + f"You need a higher role than **{role_name(target_role)}** to assign it.\n" + f"Your role: **{role_name(actor_role)}**" + )) + + async with self.config.networks() as ns: + set_staff_role(ns[name], user.id, target_role) + await ctx.send(embed=ok_embed( + f"{user.mention} is now **{role_name(target_role)}** in `{name}`." + )) + await self._audit(name, "staff_add", str(ctx.author), str(user), f"role={role_name(target_role)}") + + @wh_staff.command(name="remove", aliases=["rm"]) + async def wh_staff_rm(self, ctx: commands.Context, name: str, user: discord.User) -> None: + """Remove a user from network staff.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + + target_role = get_role(nd, user.id) + if target_role <= Role.MEMBER: + return await ctx.send(embed=err_embed(f"{user} isn't staff in `{name}`.")) + + actor_role = get_role(nd, ctx.author.id) + is_bot_owner = await self.bot.is_owner(ctx.author) + if not is_bot_owner and not can_assign(actor_role, target_role): + return await ctx.send(embed=err_embed( + f"You can't remove a **{role_name(target_role)}** — " + f"your role: **{role_name(actor_role)}**" + )) + + async with self.config.networks() as ns: + remove_staff(ns[name], user.id) + await ctx.send(embed=ok_embed(f"{user.mention} removed from staff in `{name}`.")) + await self._audit(name, "staff_rm", str(ctx.author), str(user)) + + @wh_staff.command(name="promote") + async def wh_staff_promote(self, ctx: commands.Context, name: str, user: discord.User) -> None: + """Promote a staff member one tier up.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + + current = get_role(nd, user.id) + if current >= Role.ADMIN: + return await ctx.send(embed=err_embed(f"{user} is already **{role_name(current)}** — can't promote further.")) + new_role = Role(current + 1) + if new_role >= Role.OWNER: + return await ctx.send(embed=err_embed("Can't promote to Owner. Use `wh transfer` instead.")) + + actor_role = get_role(nd, ctx.author.id) + is_bot_owner = await self.bot.is_owner(ctx.author) + if not is_bot_owner and not can_assign(actor_role, new_role): + return await ctx.send(embed=err_embed( + f"You can't assign **{role_name(new_role)}** — " + f"your role: **{role_name(actor_role)}**" + )) + + async with self.config.networks() as ns: + set_staff_role(ns[name], user.id, new_role) + await ctx.send(embed=ok_embed( + f"{user.mention} promoted: **{role_name(current)}** → **{role_name(new_role)}** in `{name}`." + )) + await self._audit(name, "promote", str(ctx.author), str(user), f"{role_name(current)} → {role_name(new_role)}") + + @wh_staff.command(name="demote") + async def wh_staff_demote(self, ctx: commands.Context, name: str, user: discord.User) -> None: + """Demote a staff member one tier down.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + + current = get_role(nd, user.id) + if current <= Role.MEMBER: + return await ctx.send(embed=err_embed(f"{user} isn't staff in `{name}`.")) + if current >= Role.OWNER: + return await ctx.send(embed=err_embed("Can't demote the Owner. Use `wh transfer` first.")) + + new_role = Role(current - 1) + + actor_role = get_role(nd, ctx.author.id) + is_bot_owner = await self.bot.is_owner(ctx.author) + if not is_bot_owner and not can_assign(actor_role, current): + return await ctx.send(embed=err_embed( + f"You can't demote a **{role_name(current)}** — " + f"your role: **{role_name(actor_role)}**" + )) + + async with self.config.networks() as ns: + if new_role <= Role.MEMBER: + remove_staff(ns[name], user.id) + else: + set_staff_role(ns[name], user.id, new_role) + label = role_name(new_role) if new_role > Role.MEMBER else "Member (removed from staff)" + await ctx.send(embed=ok_embed( + f"{user.mention} demoted: **{role_name(current)}** → **{label}** in `{name}`." + )) + await self._audit(name, "demote", str(ctx.author), str(user), f"{role_name(current)} → {label}") + + @wh_staff.command(name="list", aliases=["ls"]) + async def wh_staff_ls(self, ctx: commands.Context, name: str) -> None: + """List all staff in a network with their roles.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + + staff = list_staff(nd) + if not staff: + return await ctx.send(embed=info_embed(f"No staff in `{name}`.")) + + lines = [] + for uid, role in sorted(staff.items(), key=lambda x: -x[1]): + user = self.bot.get_user(uid) + display = str(user) if user else f"Unknown ({uid})" + emoji = {Role.OWNER: "👑", Role.ADMIN: "🛡️", Role.MODERATOR: "⚔️", Role.HELPER: "🤝"}.get(role, "") + lines.append(f"{emoji} **{role_name(role)}** — {display}") + + em = discord.Embed( + title=f"👥 Staff — {name}", + description="\n".join(lines), + colour=COLOUR_INFO, + ) + await ctx.send(embed=em) + + @commands.hybrid_command(name="wh-transfer") + async def wh_transfer(self, ctx: commands.Context, name: str, new_owner: discord.User) -> None: + """Transfer network ownership (owner or bot owner only).""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + if not has_role(nd, ctx.author.id, Role.OWNER) and not await self.bot.is_owner(ctx.author): + return await ctx.send(embed=err_embed("Only the network owner or bot owner can transfer ownership.")) + + old_owner = nd["owner_id"] + async with self.config.networks() as ns: + ns[name]["owner_id"] = new_owner.id + # Demote old owner to Admin if they're not the bot owner + if old_owner != new_owner.id: + set_staff_role(ns[name], old_owner, Role.ADMIN) + # Remove new owner from staff dict (they're owner now) + remove_staff(ns[name], new_owner.id) + + await ctx.send(embed=ok_embed( + f"Ownership of `{name}` transferred to {new_owner.mention}.\n" + f"Previous owner demoted to Admin." + )) + await self._audit(name, "transfer", str(ctx.author), str(new_owner)) diff --git a/wormhole/commands/tos.py b/wormhole/commands/tos.py new file mode 100644 index 0000000..8e4b7c3 --- /dev/null +++ b/wormhole/commands/tos.py @@ -0,0 +1,83 @@ +"""ToS / Rules acceptance gate commands.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import discord +from redbot.core import commands + +from ..models.permissions import Role, requires_role +from ..utils import ok_embed, err_embed, info_embed, COLOUR_INFO + + +class ToSCommands: + """Mixin — Terms of Service / rules acceptance gate.""" + + @commands.group(name="wh-rules", aliases=["whrules"], invoke_without_command=True) + async def wh_rules(self, ctx: commands.Context) -> None: + """Network rules / Terms of Service.""" + await ctx.send_help(ctx.command) + + @wh_rules.command(name="set") + @requires_role(Role.ADMIN) + async def wh_rules_set(self, ctx: commands.Context, name: str, *, text: str) -> None: + """Set the rules / ToS text.""" + async with self.config.networks() as ns: + ns[name]["rules_text"] = text + await ctx.send(embed=ok_embed(f"Rules updated for `{name}`.")) + + @wh_rules.command(name="require") + @requires_role(Role.ADMIN) + async def wh_rules_require(self, ctx: commands.Context, name: str, enabled: bool = True) -> None: + """Toggle mandatory rules acceptance before using the network.""" + async with self.config.networks() as ns: + ns[name]["rules_required"] = enabled + await ctx.send(embed=ok_embed( + f"Rules acceptance {'required' if enabled else 'not required'} for `{name}`." + )) + + @wh_rules.command(name="view") + async def wh_rules_view(self, ctx: commands.Context, name: str) -> None: + """View the network rules.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + text = nd.get("rules_text") or nd.get("rules") or "No rules set." + em = discord.Embed( + title=f"📜 Rules — {name}", + description=text[:4000], + colour=COLOUR_INFO, + ) + accepted = nd.get("rules_accepted", {}).get(str(ctx.author.id)) + if accepted: + em.set_footer(text=f"You accepted on {accepted[:10]}") + else: + em.set_footer(text="⚠️ You haven't accepted these rules yet. Use `wh accept` or `wh agree`.") + await ctx.send(embed=em) + + @commands.hybrid_command(name="wh-accept") + async def wh_accept(self, ctx: commands.Context, name: str) -> None: + """Accept the network rules / ToS.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + if not nd.get("rules_required"): + return await ctx.send(embed=info_embed("This network doesn't require rules acceptance.")) + async with self.config.networks() as ns: + ns[name].setdefault("rules_accepted", {})[str(ctx.author.id)] = datetime.now(timezone.utc).isoformat() + await ctx.send(embed=ok_embed(f"You've accepted the rules for `{name}`. You can now use the relay.")) + + @commands.hybrid_command(name="wh-agree") + async def wh_agree(self, ctx: commands.Context, name: str) -> None: + """Alias for ``wh accept``.""" + await self.wh_accept(ctx, name) + + @wh_rules.command(name="reset") + @requires_role(Role.ADMIN) + async def wh_rules_reset(self, ctx: commands.Context, name: str) -> None: + """Reset all acceptances (force everyone to re-accept).""" + async with self.config.networks() as ns: + ns[name]["rules_accepted"] = {} + await ctx.send(embed=ok_embed(f"All acceptances reset for `{name}`. Users must re-accept.")) + await self._audit(name, "rules_reset", str(ctx.author)) diff --git a/wormhole/core.py b/wormhole/core.py new file mode 100644 index 0000000..001fe9d --- /dev/null +++ b/wormhole/core.py @@ -0,0 +1,779 @@ +""" +Wormhole v4.0.0 — Core cog class, config, and internal helpers. +================================================================ + +This is the main cog class that Red loads. Commands are added via +mixin classes in the ``commands/`` package; event listeners live in +``listeners/``. This file contains: + +- Cog initialisation and config registration +- Internal helper methods (_net, _save, _wh, _log, _audit, …) +- Background task management +""" + +from __future__ import annotations + +import asyncio +import hashlib +import logging +import random +import time +from collections import defaultdict +from copy import deepcopy +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, Dict, List, Optional, Set + +import discord +from discord import app_commands +from redbot.core import Config, checks, commands +from redbot.core.bot import Red + +from .models.config import DEFAULT_GLOBAL, DEFAULT_NETWORK +from .models.message_map import MessageMap +from .models.permissions import Role, has_role, get_role, role_name +from .ui.modals import ReportModal +from .ui.views import reply_jump_view +from .services.emoji import resolve_foreign_emojis, build_emoji_embeds_and_files +from .utils import ( + BLOCKED_EXTENSIONS, + COLOUR_ANNOUNCE, + COLOUR_DM, + COLOUR_INFO, + COLOUR_NEUTRAL, + COLOUR_OK, + COLOUR_STAR, + CooldownBucket, + DuplicateDetector, + RaidDetector, + announce_embed, + apply_mention_policy, + build_dm_incoming_embed, + build_dm_relay_embed, + build_portal_embed, + build_relay_embed, + build_star_embed, + check_attachment_filters, + check_automod, + check_filters, + compact_format, + dm_embed, + err_embed, + format_audit_entry, + generate_invite_code, + human_timedelta, + info_embed, + ok_embed, + sanitise_mentions, + star_embed, + truncate, + warn_embed, +) + +# Command mixins +from .commands.network import NetworkCommands +from .commands.settings import SettingsCommands +from .commands.moderation import ModerationCommands +from .commands.staff import StaffCommands +from .commands.filters import FilterCommands +from .commands.social import SocialCommands +from .commands.dm import DMCommands +from .commands.advanced import AdvancedCommands +from .commands.mentions import MentionCommands +from .commands.tos import ToSCommands +from .commands.reports import ReportCommands +from .commands.bridge import BridgeCommands +from .commands.debug import DebugCommands + +# Listener mixins +from .listeners.relay import RelayListener +from .listeners.sync import SyncListener +from .listeners.misc import MiscListener + +log = logging.getLogger("red.evecogs.wormhole") + +_AUDIT_LIMIT = 500 + + +class Wormhole( + # Listeners + RelayListener, + SyncListener, + MiscListener, + # Commands + NetworkCommands, + SettingsCommands, + ModerationCommands, + StaffCommands, + FilterCommands, + SocialCommands, + DMCommands, + AdvancedCommands, + MentionCommands, + ToSCommands, + ReportCommands, + BridgeCommands, + DebugCommands, + commands.Cog, +): + """The ultimate cross-server relay cog for Red-DiscordBot.""" + + __version__ = "4.0.0" + + def __init__(self, bot: Red) -> None: + self.bot = bot + self.config = Config.get_conf(self, identifier=928374650, force_registration=True) + self.config.register_global(**DEFAULT_GLOBAL) + + # In-memory state + self.msg_map = MessageMap() + self.cooldowns: Dict[str, CooldownBucket] = {} + self.dup_detectors: Dict[str, DuplicateDetector] = {} + self.raid_detectors: Dict[str, RaidDetector] = {} + self.slowmode_tracker: Dict[str, Dict[int, float]] = {} + self._wh_cache: Dict[int, discord.Webhook] = {} + self._ready = asyncio.Event() + self._bg_tasks: List[asyncio.Task] = [] + self._trace_channels: Set[int] = set() + + async def _init(self) -> None: + """Deferred initialisation — called from setup() after cog is added.""" + await self.bot.wait_until_ready() + nets = await self.config.networks() + for name, nd in nets.items(): + r = nd.get("rate_limit_rate", 5) + p = nd.get("rate_limit_per", 10.0) + self.cooldowns[name] = CooldownBucket(r, p) + am = nd.get("automod", {}) + if am.get("anti_spam"): + self.dup_detectors[name] = DuplicateDetector( + am.get("spam_window", 30.0), am.get("spam_threshold", 3) + ) + if am.get("anti_raid"): + self.raid_detectors[name] = RaidDetector( + am.get("raid_window", 60.0), am.get("raid_threshold", 10) + ) + self._bg_tasks.append(asyncio.create_task(self._blackout_loop())) + self._bg_tasks.append(asyncio.create_task(self._portal_update_loop())) + self._bg_tasks.append(asyncio.create_task(self._scheduled_msg_loop())) + self._bg_tasks.append(asyncio.create_task(self._health_check_loop())) + self._bg_tasks.append(asyncio.create_task(self._poll_expiry_loop())) + self._register_context_menus() + self._ready.set() + log.info("Wormhole v%s ready — %d networks loaded", self.__version__, len(nets)) + + # ── Context menu registration ────────────────────────────────────────── + + def _register_context_menus(self) -> None: + """Register slash commands and context menus.""" + + # ── Context menus ── + ctx_report = app_commands.ContextMenu(name="Report to Wormhole", callback=self._ctx_report_message) + ctx_bookmark = app_commands.ContextMenu(name="Bookmark (Wormhole)", callback=self._ctx_bookmark_message) + ctx_delete = app_commands.ContextMenu(name="Delete from Network", callback=self._ctx_delete_message) + ctx_profile = app_commands.ContextMenu(name="Wormhole Profile", callback=self._ctx_view_profile) + self.bot.tree.add_command(ctx_report) + self.bot.tree.add_command(ctx_bookmark) + self.bot.tree.add_command(ctx_delete) + self.bot.tree.add_command(ctx_profile) + + # ── Slash commands ── + @app_commands.command(name="wh-list", description="List wormhole networks") + async def slash_list(interaction: discord.Interaction): + ctx = await commands.Context.from_interaction(interaction) + await self.wh_list(ctx) + + @app_commands.command(name="wh-info", description="Info about a network") + @app_commands.describe(name="Network name") + async def slash_info(interaction: discord.Interaction, name: str): + ctx = await commands.Context.from_interaction(interaction) + await self.wh_info(ctx, name) + + @app_commands.command(name="wh-accept", description="Accept network rules") + @app_commands.describe(name="Network name") + async def slash_accept(interaction: discord.Interaction, name: str): + ctx = await commands.Context.from_interaction(interaction) + await self.wh_accept(ctx, name) + + @app_commands.command(name="wh-agree", description="Agree to network ToS") + @app_commands.describe(name="Network name") + async def slash_agree(interaction: discord.Interaction, name: str): + ctx = await commands.Context.from_interaction(interaction) + await self.wh_agree(ctx, name) + + @app_commands.command(name="wh-report", description="Report a message") + @app_commands.describe(message_id="Message ID to report", reason="Reason") + async def slash_report(interaction: discord.Interaction, message_id: str, reason: str = "No reason provided"): + ctx = await commands.Context.from_interaction(interaction) + await self.wh_report_msg(ctx, int(message_id), reason=reason) + + @app_commands.command(name="wh-profile", description="View a user's wormhole profile") + @app_commands.describe(name="Network name", user="User to look up") + async def slash_profile(interaction: discord.Interaction, name: str, user: discord.User = None): + ctx = await commands.Context.from_interaction(interaction) + await self.wh_profile(ctx, name, user) + + @app_commands.command(name="wh-discover", description="Discover public networks") + async def slash_discover(interaction: discord.Interaction): + ctx = await commands.Context.from_interaction(interaction) + await self.wh_discover(ctx) + + for cmd in (slash_list, slash_info, slash_accept, slash_agree, + slash_report, slash_profile, slash_discover): + self.bot.tree.add_command(cmd) + + async def cog_unload(self) -> None: + for task in self._bg_tasks: + task.cancel() + for cmd_name in ("Report to Wormhole", "Bookmark (Wormhole)", + "Delete from Network", "Wormhole Profile", + "wh-list", "wh-info", "wh-accept", "wh-agree", + "wh-report", "wh-profile", "wh-discover"): + self.bot.tree.remove_command(cmd_name) + + # ── Background task loops ────────────────────────────────────────────── + + async def _blackout_loop(self) -> None: + await self.bot.wait_until_ready() + while True: + try: + now = datetime.now(timezone.utc) + async with self.config.networks() as nets: + for name, nd in nets.items(): + for sched in nd.get("blackout_schedules", []): + day = now.weekday() + if day not in sched.get("days", []): + continue + h = now.hour + start, end = sched["start_hour"], sched["end_hour"] + in_window = ( + (start <= h < end) if start < end else (h >= start or h < end) + ) + if in_window and not nd.get("frozen"): + nd["frozen"] = True + log.info("Blackout freeze: %s", name) + elif not in_window and nd.get("frozen") and nd.get("_blackout_froze"): + nd["frozen"] = False + nd.pop("_blackout_froze", None) + if in_window: + nd["_blackout_froze"] = True + except Exception as exc: + log.error("Blackout loop error: %s", exc) + await asyncio.sleep(60) + + async def _portal_update_loop(self) -> None: + await self.bot.wait_until_ready() + while True: + try: + nets = await self.config.networks() + for name, nd in nets.items(): + for ch_id_s, msg_id in list(nd.get("portal_messages", {}).items()): + ch = self.bot.get_channel(int(ch_id_s)) + if not ch: + continue + try: + msg = await ch.fetch_message(msg_id) + em = build_portal_embed( + name, nd, len(nd.get("channels", [])), + nd.get("total_messages", 0), + ) + await msg.edit(embed=em) + except Exception: + pass + except Exception as exc: + log.error("Portal loop error: %s", exc) + await asyncio.sleep(300) + + async def _scheduled_msg_loop(self) -> None: + await self.bot.wait_until_ready() + while True: + try: + now = datetime.now(timezone.utc) + async with self.config.networks() as nets: + for name, nd in nets.items(): + remaining = [] + for sm in nd.get("scheduled_messages", []): + send_at = datetime.fromisoformat(sm["send_at_iso"]) + if now >= send_at: + for ch_id in nd.get("channels", []): + ch = self.bot.get_channel(ch_id) + if ch: + try: + await ch.send( + embed=announce_embed( + sm["content"], + title=f"📅 Scheduled — {name}", + ) + ) + except Exception: + pass + else: + remaining.append(sm) + nd["scheduled_messages"] = remaining + except Exception as exc: + log.error("Scheduled msg loop error: %s", exc) + await asyncio.sleep(30) + + async def _health_check_loop(self) -> None: + await self.bot.wait_until_ready() + while True: + try: + async with self.config.networks() as nets: + for name, nd in nets.items(): + unhealthy = [] + for ch_id in nd.get("channels", []): + ch = self.bot.get_channel(ch_id) + if not ch: + unhealthy.append(ch_id) + continue + perms = ch.permissions_for(ch.guild.me) + if not perms.send_messages or not perms.manage_webhooks: + unhealthy.append(ch_id) + nd["unhealthy_channels"] = unhealthy + nd["last_health_check"] = datetime.now(timezone.utc).isoformat() + except Exception as exc: + log.error("Health check error: %s", exc) + await asyncio.sleep(600) + + async def _poll_expiry_loop(self) -> None: + await self.bot.wait_until_ready() + while True: + try: + now = datetime.now(timezone.utc) + async with self.config.networks() as nets: + for name, nd in nets.items(): + expired = [] + for pid, poll in list(nd.get("active_polls", {}).items()): + exp = datetime.fromisoformat(poll["expires"]) + if now >= exp: + expired.append(pid) + results = self._format_poll_results(poll) + for ch_id in nd.get("channels", []): + ch = self.bot.get_channel(ch_id) + if ch: + try: + await ch.send( + embed=info_embed(results, title=f"📊 Poll Ended: {poll['question']}") + ) + except Exception: + pass + for pid in expired: + nd.get("active_polls", {}).pop(pid, None) + except Exception as exc: + log.error("Poll expiry error: %s", exc) + await asyncio.sleep(30) + + @staticmethod + def _format_poll_results(poll: dict) -> str: + options = poll.get("options", []) + votes = poll.get("votes", {}) + lines = [] + total = sum(len(v) for v in votes.values()) + emojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"] + for i, opt in enumerate(options): + count = len(votes.get(str(i), [])) + pct = (count / total * 100) if total else 0 + bar = "█" * int(pct / 5) + "░" * (20 - int(pct / 5)) + emoji = emojis[i] if i < len(emojis) else f"{i+1}." + lines.append(f"{emoji} **{opt}** — {count} votes ({pct:.0f}%)\n`{bar}`") + lines.append(f"\n**Total votes:** {total}") + return "\n".join(lines) + + # ── Config helpers ───────────────────────────────────────────────────── + + async def _net(self, name: str) -> Optional[dict]: + nets = await self.config.networks() + return nets.get(name) + + async def _save(self, name: str, data: dict) -> None: + async with self.config.networks() as nets: + nets[name] = data + + async def _net_for_ch(self, ch_id: int) -> Optional[str]: + nets = await self.config.networks() + for n, d in nets.items(): + if ch_id in d.get("channels", []): + return n + return None + + # ── Webhook helper ───────────────────────────────────────────────────── + + async def _wh(self, ch: discord.TextChannel, *, force_refresh: bool = False) -> discord.Webhook: + if not force_refresh and ch.id in self._wh_cache: + return self._wh_cache[ch.id] + hooks = await ch.webhooks() + wh = discord.utils.get(hooks, name="Wormhole") + if not wh: + wh = await ch.create_webhook(name="Wormhole") + self._wh_cache[ch.id] = wh + return wh + + # ── Identity helpers ─────────────────────────────────────────────────── + + def _avatar(self, msg: discord.Message, mode: str, icon: Optional[str]) -> str: + if mode == "server" and msg.guild and msg.guild.icon: + return msg.guild.icon.url + if mode == "custom" and icon: + return icon + return msg.author.display_avatar.url + + def _name(self, msg: discord.Message, mode: str, custom: Optional[str], nick: Optional[str] = None) -> str: + server = nick or msg.guild.name + user = msg.author.display_name + if mode == "server": + return server + if mode == "custom" and custom: + return custom + if mode == "user": + return user + return f"{user} @ {server}" + + # ── Anonymous mode ───────────────────────────────────────────────────── + + def _anon_name(self, net_data: dict, user_id: int) -> str: + salt = net_data.get("anon_salt", "") + h = hashlib.md5(f"{salt}{user_id}".encode()).hexdigest()[:6] + return f"Anon-{h}" + + def _anon_avatar(self, user_id: int) -> str: + idx = user_id % 5 + return f"https://cdn.discordapp.com/embed/avatars/{idx}.png" + + # ── Logging / audit ──────────────────────────────────────────────────── + + async def _log(self, d: dict, embed: discord.Embed) -> None: + ch_id = d.get("log_channel") + if ch_id: + ch = self.bot.get_channel(ch_id) + if ch: + try: + await ch.send(embed=embed) + except Exception: + pass + + async def _audit(self, name: str, action: str, user: str, target: str = "", details: str = "") -> None: + entry = format_audit_entry(action, user, target, details) + async with self.config.networks() as nets: + if name in nets: + log_list = nets[name].setdefault("audit_log", []) + log_list.append(entry) + if len(log_list) > _AUDIT_LIMIT: + nets[name]["audit_log"] = log_list[-_AUDIT_LIMIT:] + + async def _status(self, net_name: str, net_data: dict, src_ch: Optional[int], text: str) -> None: + for ch_id in net_data.get("channels", []): + if ch_id == src_ch: + continue + ch = self.bot.get_channel(ch_id) + if ch: + try: + await ch.send(embed=info_embed(text, title=f"🌀 {net_name}")) + except Exception: + pass + + # ── Profile tracking ─────────────────────────────────────────────────── + + async def _update_profile(self, name: str, user: discord.User, guild_id: int) -> None: + async with self.config.networks() as nets: + if name not in nets: + return + profiles = nets[name].setdefault("user_profiles", {}) + uid = str(user.id) + if uid not in profiles: + profiles[uid] = { + "display_name": user.display_name, + "avatar_url": str(user.display_avatar.url), + "first_seen": datetime.now(timezone.utc).isoformat(), + "message_count": 0, + "servers": [], + } + profiles[uid]["message_count"] = profiles[uid].get("message_count", 0) + 1 + profiles[uid]["display_name"] = user.display_name + profiles[uid]["avatar_url"] = str(user.display_avatar.url) + if guild_id not in profiles[uid].get("servers", []): + profiles[uid].setdefault("servers", []).append(guild_id) + + # ── Highlight notifications ──────────────────────────────────────────── + + async def _check_highlights(self, net_name: str, net_data: dict, content: str, author_id: int) -> None: + if not content: + return + lowered = content.lower() + for uid_str, keywords in net_data.get("highlights", {}).items(): + uid = int(uid_str) + if uid == author_id: + continue + for kw in keywords: + if kw.lower() in lowered: + user = self.bot.get_user(uid) + if user: + try: + await user.send( + embed=info_embed( + f"Keyword **{kw}** mentioned in `{net_name}`:\n>>> {truncate(content, 200)}", + title="🔔 Highlight", + ) + ) + except discord.Forbidden: + pass + break + + # ── AFK system ───────────────────────────────────────────────────────── + + async def _check_afk(self, net_name: str, net_data: dict, message: discord.Message) -> None: + afk = net_data.get("afk_users", {}) + uid_str = str(message.author.id) + + # Return from AFK + if uid_str in afk: + since = afk[uid_str].get("since_iso", "") + async with self.config.networks() as nets: + if net_name in nets: + nets[net_name].get("afk_users", {}).pop(uid_str, None) + try: + await message.channel.send( + embed=info_embed(f"Welcome back, {message.author.mention}! AFK removed."), + delete_after=10, + ) + except Exception: + pass + + # Notify about mentioned AFK users + if message.mentions: + for mentioned in message.mentions: + muid = str(mentioned.id) + if muid in afk: + reason = afk[muid].get("reason", "AFK") + try: + await message.channel.send( + embed=info_embed( + f"{mentioned.display_name} is AFK: **{reason}**" + ), + delete_after=10, + ) + except Exception: + pass + + # ── Auto-responses ───────────────────────────────────────────────────── + + async def _check_auto_responses(self, net_name: str, net_data: dict, message: discord.Message) -> None: + if not message.content: + return + ars = net_data.get("auto_responses", {}) + now = time.time() + for trigger, cfg in ars.items(): + is_regex = cfg.get("regex", False) + cooldown = cfg.get("cooldown", 0) + last_used = cfg.get("last_used", 0) + if cooldown and (now - last_used) < cooldown: + continue + matched = False + if is_regex: + try: + if __import__("re").search(trigger, message.content, __import__("re").IGNORECASE): + matched = True + except Exception: + pass + else: + if trigger.lower() in message.content.lower(): + matched = True + if matched: + reply = cfg.get("reply", "") + if reply: + try: + await message.channel.send(reply) + except Exception: + pass + async with self.config.networks() as nets: + if net_name in nets: + ar = nets[net_name].get("auto_responses", {}).get(trigger) + if ar: + ar["last_used"] = now + + # ── Ephemeral delete ─────────────────────────────────────────────────── + + async def _schedule_ephemeral_delete(self, msg: discord.Message, delay: int) -> None: + async def _delete_after(): + await asyncio.sleep(delay) + try: + await msg.delete() + except Exception: + pass + + asyncio.create_task(_delete_after()) + + # ── Quiet hours check ────────────────────────────────────────────────── + + def _is_quiet_hour(self, net_data: dict, user_id: int) -> bool: + qh = net_data.get("quiet_hours", {}).get(str(user_id)) + if not qh: + return False + now = datetime.now(timezone.utc) + timedelta(hours=qh.get("tz_offset", 0)) + h = now.hour + start, end = qh["start_hour"], qh["end_hour"] + if start < end: + return start <= h < end + return h >= start or h < end + + # ── DM relay ─────────────────────────────────────────────────────────── + + async def _relay_to_dm_subs(self, net_name: str, net_data: dict, message: discord.Message) -> None: + if not net_data.get("dm_enabled"): + return + subs = net_data.get("dm_subscribers", []) + if not subs: + return + for uid in subs: + if uid == message.author.id: + continue + if self._is_quiet_hour(net_data, uid): + continue + # Check personal ignore list + ignores = net_data.get("user_ignores", {}).get(str(uid), []) + if message.author.id in ignores: + continue + user = self.bot.get_user(uid) + if not user: + continue + dm_mode = net_data.get("dm_relay_mode", "embed") + try: + if dm_mode == "embed": + em = build_dm_incoming_embed( + message.author.display_name, + str(message.author.display_avatar.url), + message.guild.name, + message.channel.name, + message.content, + net_name, + net_data.get("colour"), + ) + await user.send(embed=em) + elif dm_mode == "compact": + await user.send( + content=f"**[{net_name}] {message.author.display_name}:** {truncate(message.content, 1800)}" + ) + else: + await user.send(content=truncate(message.content, 1900)) + except discord.Forbidden: + pass + + # ── Analytics ────────────────────────────────────────────────────────── + + async def _record_analytics(self, net_name: str, user_id: int) -> None: + now = datetime.now(timezone.utc) + hour_key = now.strftime("%Y-%m-%d-%H") + uid_str = str(user_id) + async with self.config.networks() as nets: + if net_name not in nets: + return + analytics = nets[net_name].setdefault("analytics", {"hourly": {}, "top_users": {}}) + analytics["hourly"][hour_key] = analytics["hourly"].get(hour_key, 0) + 1 + analytics["top_users"][uid_str] = analytics["top_users"].get(uid_str, 0) + 1 + # Prune old hourly data (keep 7 days) + cutoff = (now - timedelta(days=7)).strftime("%Y-%m-%d-%H") + analytics["hourly"] = { + k: v for k, v in analytics["hourly"].items() if k >= cutoff + } + + # ── Per-channel override helper ──────────────────────────────────────── + + def _get_override(self, net_data: dict, ch_id: int, key: str): + overrides = net_data.get("channel_overrides", {}).get(str(ch_id), {}) + return overrides.get(key) + + # ── Context menu callbacks ───────────────────────────────────────────── + + async def _ctx_report_message(self, interaction: discord.Interaction, message: discord.Message) -> None: + net_name = await self._net_for_ch(message.channel.id) + if not net_name: + await interaction.response.send_message("This channel isn't in a wormhole network.", ephemeral=True) + return + nd = await self._net(net_name) + modal = ReportModal(self, net_name, message) + await interaction.response.send_modal(modal) + + async def _ctx_bookmark_message(self, interaction: discord.Interaction, message: discord.Message) -> None: + uid_str = str(interaction.user.id) + bookmark = { + "content": truncate(message.content or "*[no text]*", 500), + "author": str(message.author), + "server": message.guild.name if message.guild else "DM", + "channel": message.channel.name if hasattr(message.channel, "name") else "DM", + "timestamp": datetime.now(timezone.utc).isoformat(), + "jump_url": message.jump_url, + } + async with self.config.bookmarks() as bm: + bm.setdefault(uid_str, []).append(bookmark) + if len(bm[uid_str]) > 50: + bm[uid_str] = bm[uid_str][-50:] + await interaction.response.send_message( + embed=ok_embed("Message bookmarked! Use `wh bm list` to view."), + ephemeral=True, + ) + + async def _ctx_delete_message(self, interaction: discord.Interaction, message: discord.Message) -> None: + net_name = await self._net_for_ch(message.channel.id) + if not net_name: + await interaction.response.send_message("Not in a network.", ephemeral=True) + return + nd = await self._net(net_name) + if not has_role(nd, interaction.user.id, Role.MODERATOR) and not await self.bot.is_owner(interaction.user): + await interaction.response.send_message("You need Moderator role or higher.", ephemeral=True) + return + # Find and delete all relayed copies + orig_id = self.msg_map.get_original(net_name, message.id) or message.id + relayed = self.msg_map.get_relayed(net_name, orig_id) + deleted = 0 + for ch_id, msg_id in relayed.items(): + ch = self.bot.get_channel(ch_id) + if ch: + try: + m = await ch.fetch_message(msg_id) + await m.delete() + deleted += 1 + except Exception: + pass + try: + await message.delete() + deleted += 1 + except Exception: + pass + await interaction.response.send_message( + embed=ok_embed(f"Deleted {deleted} message(s) across the network."), + ephemeral=True, + ) + await self._audit(net_name, "ctx_delete", str(interaction.user), str(orig_id)) + + async def _ctx_view_profile(self, interaction: discord.Interaction, user: discord.User) -> None: + nets = await self.config.networks() + profiles_found = [] + for name, nd in nets.items(): + p = nd.get("user_profiles", {}).get(str(user.id)) + if p: + role = get_role(nd, user.id) + profiles_found.append((name, p, role)) + if not profiles_found: + await interaction.response.send_message("No wormhole profile found for this user.", ephemeral=True) + return + em = discord.Embed( + title=f"🌀 Wormhole Profile — {user.display_name}", + colour=COLOUR_INFO, + ) + em.set_thumbnail(url=user.display_avatar.url) + for name, p, role in profiles_found[:10]: + em.add_field( + name=f"Network: {name}", + value=( + f"Role: **{role_name(role)}**\n" + f"Messages: {p.get('message_count', 0):,}\n" + f"First seen: {p.get('first_seen', 'Unknown')[:10]}" + ), + inline=True, + ) + await interaction.response.send_message(embed=em, ephemeral=True) + + # ── Root command group ───────────────────────────────────────────────── + + @commands.hybrid_group(name="wh", aliases=["wormhole"], invoke_without_command=True) + async def wh(self, ctx: commands.Context) -> None: + """🌀 Wormhole — the ultimate cross-server relay.""" + await ctx.send_help(ctx.command) diff --git a/wormhole/info.json b/wormhole/info.json index 414929b..3d345d2 100644 --- a/wormhole/info.json +++ b/wormhole/info.json @@ -1,32 +1,15 @@ { "author": ["everestmcarthur"], - "install_msg": "Thanks for installing **Wormhole v3.4.3** — the ultimate cross-server relay!\n\nGet started: `[p]wh create ` then `[p]wh open ` in channels you want to link.\n\nNew in v3.4.3: Fixed critical relay bug — raw string in mention_policy regex replacement caused every message to crash in _do_relay. Also added diagnostic commands (relaydebug, testsend, tracemode). v3.4.2: Fixed empty-prefix command filter. v3.4.1: Relay engine hardening.\n\nFull help: `[p]wh help` or `/wh help`", + "install_msg": "Thanks for installing **Wormhole v4.0.0** — the ultimate cross-server relay!\n\nGet started: `[p]wh create ` then `[p]wh open ` in channels you want to link.\n\n**v4.0.0 — Full Refactor:**\n• Modular architecture: models, commands, listeners, services, UI all in separate files\n• Per-network hierarchical staff system: Owner → Admin → Moderator → Helper → Member\n• Enhanced moderation: server ban/mute, allowlist, purge, mod edit/nuke, anti-raid\n• All 100+ commands work as both prefix and slash commands\n• Full relay pipeline with emoji resolution, reply jump buttons, starboard, karma\n\nFull help: `[p]wh help` or `/wh help`", "name": "Wormhole", "disabled": false, - "short": "The ultimate cross-server relay — hybrid commands, context menus, ToS, reports, mentions policy, 85+ commands.", - "description": "Wormhole v3.4.0 connects Discord channels across multiple servers into named networks. All commands work as both slash commands and prefix commands (hybrid). Features: real-time message relay (webhooks/embeds/compact), edit/delete/reaction/reply/sticker/pin/typing sync, bidirectional DM relay, auto-moderation engine, granular mention policy (per-network + per-server + per-user), Terms of Service acceptance gate with legal template, user report system with mod actions, context menu actions (report/bookmark/delete/profile), cross-network starboard, karma system, keyword highlights, invite codes with vanity URLs, staff announcements, portal embeds, user profiles, backup/restore, blackout scheduling, anonymous mode, network-wide polls, AFK system, auto-responses, ephemeral messages, analytics dashboard, health monitoring, message bookmarks, user colours, quiet hours, network bridging, and much more.", + "short": "The ultimate cross-server relay — modular architecture, hierarchical staff, 100+ hybrid commands.", + "description": "Wormhole v4.0.0 is a fully modular cross-server relay cog for Red-DiscordBot. Features: real-time message relay (webhooks/embeds/compact), hierarchical per-network staff system (Owner/Admin/Moderator/Helper), edit/delete/reaction/reply/sticker/pin/typing sync, bidirectional DM relay, advanced auto-moderation (spam/raid/invite/caps/zalgo/mention-spam), granular mention policy (per-network + per-server + per-user), Terms of Service acceptance gate, user report system, context menu actions, cross-network starboard, karma system, keyword highlights, invite codes with vanity URLs, announcements, portal embeds, user profiles, backup/restore, blackout scheduling, anonymous mode, network-wide polls, AFK system, auto-responses, ephemeral messages, analytics dashboard, health monitoring, message bookmarks, user colours, quiet hours, network bridging/mirroring, emoji relay for foreign servers, and much more.", "tags": [ - "relay", - "crossserver", - "wormhole", - "chat", - "bridge", - "network", - "webhook", - "moderation", - "automod", - "starboard", - "karma", - "dm", - "global", - "analytics", - "polls", - "anonymous", - "hybrid", - "slash", - "contextmenu", - "reports", - "tos" + "relay", "crossserver", "wormhole", "chat", "bridge", "network", + "webhook", "moderation", "automod", "starboard", "karma", "dm", + "global", "analytics", "polls", "anonymous", "hybrid", "slash", + "contextmenu", "reports", "tos", "hierarchy", "staff", "modular" ], "type": "COG", "end_user_data_statement": "This cog stores Discord user IDs, guild IDs, and channel IDs for relay functionality. Per-user data includes message counts, karma scores, keyword highlights, DM relay subscriptions, bookmarks, AFK status, ignore lists, colour preferences, quiet hour settings, analytics counters, ToS acceptance timestamps, and report records.", diff --git a/wormhole/listeners/__init__.py b/wormhole/listeners/__init__.py new file mode 100644 index 0000000..dcde426 --- /dev/null +++ b/wormhole/listeners/__init__.py @@ -0,0 +1 @@ +"""Event listeners — the relay pipeline and sync handlers.""" diff --git a/wormhole/listeners/misc.py b/wormhole/listeners/misc.py new file mode 100644 index 0000000..c1e09f4 --- /dev/null +++ b/wormhole/listeners/misc.py @@ -0,0 +1,66 @@ +"""Misc listeners — typing indicator sync, pin sync.""" + +from __future__ import annotations + +import logging + +import discord +from redbot.core import commands + +log = logging.getLogger("red.evecogs.wormhole") + + +class MiscListener: + """Mixin — typing and pin sync.""" + + @commands.Cog.listener() + async def on_typing(self, channel, user, when) -> None: + await self._ready.wait() + if user.bot or not hasattr(channel, "guild") or not channel.guild: + return + net = await self._net_for_ch(channel.id) + if not net: + return + nd = await self._net(net) + if not nd or not nd.get("sync_typing"): + return + for ch_id in nd["channels"]: + if ch_id == channel.id: + continue + ch = self.bot.get_channel(ch_id) + if ch: + try: + await ch.typing() + except Exception: + pass + + @commands.Cog.listener() + async def on_guild_channel_pins_update(self, channel, last_pin) -> None: + await self._ready.wait() + if not hasattr(channel, "guild"): + return + net = await self._net_for_ch(channel.id) + if not net: + return + nd = await self._net(net) + if not nd or not nd.get("sync_pins"): + return + try: + pins = await channel.pins() + if not pins: + return + latest = pins[0] + mapping = self.msg_map.get_relayed(net, latest.id) + if not mapping: + return + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: + continue + try: + m = await ch.fetch_message(mid) + await m.pin() + except Exception: + pass + except Exception: + pass diff --git a/wormhole/listeners/relay.py b/wormhole/listeners/relay.py new file mode 100644 index 0000000..c188c83 --- /dev/null +++ b/wormhole/listeners/relay.py @@ -0,0 +1,511 @@ +""" +on_message → relay pipeline and _do_relay core logic. + +This is the heart of Wormhole — every message in a network channel flows +through on_message → gate checks → _do_relay → send to all target channels. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Dict, Optional + +import discord +from redbot.core import commands + +from ..models.permissions import Role +from ..services.emoji import resolve_foreign_emojis, build_emoji_embeds_and_files +from ..ui.views import reply_jump_view +from ..utils import ( + apply_mention_policy, + build_relay_embed, + check_attachment_filters, + check_automod, + check_filters, + compact_format, + info_embed, + sanitise_mentions, + truncate, + warn_embed, +) + +log = logging.getLogger("red.evecogs.wormhole") + + +class RelayListener: + """Mixin — on_message listener and _do_relay engine.""" + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + await self._ready.wait() + if not message.guild or message.author.bot: + return + if not isinstance(message.channel, (discord.TextChannel, discord.Thread)): + return + + _tracing = message.channel.id in self._trace_channels + trace: list[str] = [] + if _tracing: + trace.append("✅ Guild text-channel message from non-bot") + + eff_ch = message.channel.id + is_thread = isinstance(message.channel, discord.Thread) + if is_thread: + eff_ch = message.channel.parent_id + + nets = await self.config.networks() + net_name = None + nd = None + for n, d in nets.items(): + if eff_ch in d.get("channels", []): + net_name = n + nd = d + break + if not net_name: + if _tracing: + trace.append(f"❌ **Channel `{eff_ch}` not in any network**") + try: + await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except Exception: + pass + return + + if _tracing: + trace.append(f"✅ Network: `{net_name}`") + + # ── Gate checks ──────────────────────────────────────────────────── + + if is_thread and not nd.get("sync_threads"): + if _tracing: + trace.append("❌ **Blocked: thread sync disabled**") + try: + await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except Exception: + pass + return + + if nd.get("frozen"): + if _tracing: + trace.append("❌ **Blocked: network frozen**") + try: + await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except Exception: + pass + return + + # Command filtering + if message.content: + try: + ctx = await self.bot.get_context(message) + if ctx.valid: + if _tracing: + trace.append(f"❌ **Blocked: command filter** prefix=`{ctx.prefix!r}` cmd=`{ctx.command}`") + try: + await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except Exception: + pass + return + except Exception: + pass + + # Mirror channel (receive-only) + if eff_ch in nd.get("mirror_channels", []): + return + + # Media-only filter + if nd.get("media_only"): + if not message.attachments and not message.stickers and not ( + message.embeds and any(e.type in ("image", "video", "gifv") for e in message.embeds) + ): + return + + # NSFW gate + if nd.get("nsfw_gate") and hasattr(message.channel, "is_nsfw") and message.channel.is_nsfw(): + return + + # Global blocklists + if message.author.id in await self.config.global_banned_users(): + return + if message.guild.id in await self.config.global_banned_servers(): + return + + # Per-network blocklists + if message.author.id in nd.get("banned_users", []): + return + if message.author.id in nd.get("muted_users", []): + return + if message.guild.id in nd.get("banned_servers", []): + return + if message.guild.id in nd.get("muted_servers", []): + return + + # Rules acceptance gate + if nd.get("rules_required"): + accepted = nd.get("rules_accepted", {}) + if str(message.author.id) not in accepted: + try: + prefix = await self.bot.get_prefix(message) + if isinstance(prefix, list): + prefix = prefix[0] + await message.channel.send( + embed=warn_embed( + f"{message.author.mention}, you must accept the network rules before messaging.\n" + f"Use `{prefix}wh accept {net_name}` to view and accept." + ), + delete_after=15, + ) + except Exception: + pass + return + + # Content filters + if message.content: + if check_filters(message.content, nd.get("word_filters", []), nd.get("regex_filters", [])): + try: + await message.delete() + await message.channel.send( + embed=warn_embed(f"{message.author.mention}, blocked by filter."), delete_after=5 + ) + except Exception: + pass + return + + # Attachment filters + if message.attachments: + exts = set(nd.get("blocked_extensions", [])) + reason = check_attachment_filters(message.attachments, exts, nd.get("max_filesize")) + if reason: + try: + await message.delete() + await message.channel.send( + embed=warn_embed(f"{message.author.mention}, {reason}"), delete_after=5 + ) + except Exception: + pass + return + + # Auto-moderation + am = nd.get("automod", {}) + if am.get("enabled") and message.content: + if am.get("anti_spam"): + det = self.dup_detectors.get(net_name) + if det and det.is_duplicate(net_name, message.author.id, message.content): + try: + await message.delete() + await message.channel.send(embed=warn_embed("Spam detected."), delete_after=5) + except Exception: + pass + await self._log(nd, warn_embed(f"Auto-mod spam: {message.author}")) + return + if am.get("anti_raid"): + rd = self.raid_detectors.get(net_name) + if rd and rd.record(net_name, message.author.id): + async with self.config.networks() as ns: + if net_name in ns: + ns[net_name]["frozen"] = True + await self._log(nd, warn_embed("🚨 Raid detected! Network auto-frozen.")) + await self._status( + net_name, nd, None, + "🚨 **Raid detected — network auto-frozen!** Staff: use `wh set unfreeze` to unfreeze.", + ) + return + reason = check_automod(message.content, am) + if reason: + try: + await message.delete() + await message.channel.send( + embed=warn_embed(f"{message.author.mention}: {reason}"), delete_after=5 + ) + except Exception: + pass + await self._log(nd, warn_embed(f"Auto-mod: {reason} — {message.author}")) + return + + # Rate limit + bucket = self.cooldowns.get(net_name) + if bucket and bucket.is_rate_limited(message.author.id, net_name): + try: + await message.add_reaction("🕐") + except Exception: + pass + return + + # Slowmode + sm = nd.get("slowmode", 0) + if sm > 0: + last = self.slowmode_tracker.get(net_name, {}).get(message.author.id, 0) + if time.monotonic() - last < sm: + try: + await message.add_reaction("🐌") + except Exception: + pass + return + self.slowmode_tracker.setdefault(net_name, {})[message.author.id] = time.monotonic() + + # Relay delay + delay = nd.get("relay_delay", 0) + if delay > 0: + await asyncio.sleep(min(delay, 30)) + + # AFK system + try: + await self._check_afk(net_name, nd, message) + except Exception as exc: + log.debug("AFK check error (relay continues): %s", exc) + + # Auto-responses + try: + await self._check_auto_responses(net_name, nd, message) + except Exception as exc: + log.debug("Auto-response error (relay continues): %s", exc) + + # ── Relay ────────────────────────────────────────────────────────── + if _tracing: + trace.append("✅ All gate checks passed — calling _do_relay") + try: + await self._do_relay(net_name, nd, nets, message, eff_ch) + if _tracing: + trace.append("✅ **_do_relay completed successfully**") + try: + await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace — RELAYED")) + except Exception: + pass + except Exception as exc: + log.error("Relay engine error net=%s ch=%s: %s", net_name, message.channel.id, exc, exc_info=True) + if _tracing: + trace.append(f"❌ **_do_relay EXCEPTION: `{exc}`**") + try: + await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace — ERROR")) + except Exception: + pass + + async def _do_relay( + self, net_name: str, nd: dict, nets: dict, message: discord.Message, eff_ch: int + ) -> None: + """Core relay logic — build payload, send to all target channels.""" + + relay_mode = nd.get("relay_mode", "webhook") + nick = nd.get("server_nicknames", {}).get(str(message.guild.id)) + + # ── Mention policy ───────────────────────────────────────────────── + mp = nd.get("mention_policy", {}) + server_overrides = nd.get("server_mention_overrides", {}).get(str(message.guild.id)) + active_policy = server_overrides if server_overrides else mp + exempt = nd.get("mention_exempt_users", []) + optouts = set(nd.get("mention_optout_users", [])) + if active_policy: + content = apply_mention_policy( + message.content or "", active_policy, message.author.id, exempt, optouts + ) + else: + mc = nd.get("mention_control", {}) + content = sanitise_mentions(message.content or "", mc) + + # ── Identity ─────────────────────────────────────────────────────── + is_anon = nd.get("anonymous", False) + if is_anon: + anon_name = self._anon_name(nd, message.author.id) + avatar = self._anon_avatar(message.author.id) + uname = anon_name + else: + avatar = self._avatar(message, nd.get("image_mode", "user"), nd.get("custom_icon")) + uname = self._name(message, nd.get("name_mode", "both"), nd.get("custom_name"), nick) + + user_colour = nd.get("user_colours", {}).get(str(message.author.id)) + + # ── Reply context ────────────────────────────────────────────────── + reply_jump_urls: Dict[int, str] = {} + reply_fallback_url: Optional[str] = None + if nd.get("sync_replies") and message.reference and message.reference.message_id: + try: + ref = message.reference.cached_message or await message.channel.fetch_message( + message.reference.message_id + ) + ref_name = self._anon_name(nd, ref.author.id) if is_anon else ref.author.display_name + preview = truncate(ref.content, 100) if ref.content else "*[attachment]*" + content = f"> **↩ {ref_name}:** {preview}\n{content}" + + if ref.guild: + reply_fallback_url = ( + f"https://discord.com/channels/{ref.guild.id}/{ref.channel.id}/{ref.id}" + ) + orig_ref_id = self.msg_map.get_original(net_name, ref.id) + ref_relayed = ( + self.msg_map.forward.get(net_name, {}).get(orig_ref_id, {}) + if orig_ref_id + else self.msg_map.forward.get(net_name, {}).get(ref.id, {}) + ) + for cid, mid in ref_relayed.items(): + target_ch = self.bot.get_channel(cid) + if target_ch and target_ch.guild: + reply_jump_urls[cid] = ( + f"https://discord.com/channels/{target_ch.guild.id}/{cid}/{mid}" + ) + if orig_ref_id: + relayed_ch_ids = set(ref_relayed.keys()) + for net_ch_id in nd.get("channels", []): + if net_ch_id not in relayed_ch_ids and net_ch_id not in reply_jump_urls: + net_ch = self.bot.get_channel(net_ch_id) + if net_ch and net_ch.guild: + reply_jump_urls[net_ch_id] = ( + f"https://discord.com/channels/{net_ch.guild.id}/{net_ch_id}/{orig_ref_id}" + ) + reply_jump_urls.setdefault(ref.channel.id, reply_fallback_url) + except Exception: + content = f"> ↩ *[reply]*\n{content}" + + # ── Stickers ─────────────────────────────────────────────────────── + if nd.get("sync_stickers") and message.stickers: + sl = [f"[Sticker: {s.name}]({s.url})" for s in message.stickers] + content = (content + "\n" if content else "") + "\n".join(sl) + + # ── Forward embeds ───────────────────────────────────────────────── + extra_embeds: list[discord.Embed] = [] + if nd.get("forward_embeds") and message.embeds: + extra_embeds = [e for e in message.embeds if e.type == "rich"] + + # ── Resolve foreign emojis ───────────────────────────────────────── + emoji_img_data: list[tuple[str, bytes]] = [] + if content: + content, emoji_img_data = await resolve_foreign_emojis(self.bot, content) + + # ── Build target list ────────────────────────────────────────────── + relay_targets = [cid for cid in nd["channels"] if cid != eff_ch] + for bridge_net in nd.get("bridge_to", []): + bd = nets.get(bridge_net) + if bd and not bd.get("frozen"): + relay_targets.extend(bd.get("channels", [])) + + # ── Send to each target channel ──────────────────────────────────── + mapping: Dict[int, int] = {} + for ch_id in relay_targets: + if ch_id == eff_ch: + continue + ch = self.bot.get_channel(ch_id) + if not ch: + continue + ch_mode = self._get_override(nd, ch_id, "relay_mode") or relay_mode + + # Reply jump button + jump_url = reply_jump_urls.get(ch_id) or reply_fallback_url + _reply_view = reply_jump_view(jump_url) if jump_url else None + _view_kw = {"view": _reply_view} if _reply_view else {} + + # Fresh emoji embeds/files (File objects are single-use) + emoji_embeds, emoji_files = build_emoji_embeds_and_files(emoji_img_data) + + try: + sent_msg = None + if ch_mode == "webhook": + sent_msg = await self._relay_webhook( + ch, message, content, uname, avatar, extra_embeds, + emoji_embeds, emoji_files, emoji_img_data, _view_kw, nick, nd, + ) + elif ch_mode == "embed": + ee, ef = build_emoji_embeds_and_files(emoji_img_data) + em = build_relay_embed(message, nick, user_colour or nd.get("colour")) + all_embeds = [em] + (extra_embeds or []) + ee + sent_msg = await ch.send(embeds=all_embeds[:10], files=ef or None, **_view_kw) + elif ch_mode == "compact": + g = nick or message.guild.name + display = anon_name if is_anon else message.author.display_name + files = [] + for a in message.attachments: + try: + files.append(await a.to_file()) + except Exception: + pass + ce, cf = build_emoji_embeds_and_files(emoji_img_data) + files.extend(cf) + sent_msg = await ch.send( + content=compact_format(g, display, content), + files=files or None, + embeds=ce[:10] or None, + **_view_kw, + ) + + if sent_msg: + mapping[ch_id] = sent_msg.id + if nd.get("ephemeral_delay", 0) > 0: + await self._schedule_ephemeral_delete(sent_msg, nd["ephemeral_delay"]) + + except Exception as exc: + log.error("Relay fail ch=%s net=%s: %s", ch_id, net_name, exc, exc_info=True) + + if mapping: + self.msg_map.add(net_name, message.id, mapping) + + # Stats + profile + analytics + async with self.config.networks() as ns: + if net_name in ns: + ns[net_name]["total_messages"] = ns[net_name].get("total_messages", 0) + 1 + await self._update_profile(net_name, message.author, message.guild.id) + await self._record_analytics(net_name, message.author.id) + + # DM relay + highlights + await self._relay_to_dm_subs(net_name, nd, message) + await self._check_highlights(net_name, nd, message.content, message.author.id) + + async def _relay_webhook( + self, ch, message, content, uname, avatar, extra_embeds, + emoji_embeds, emoji_files, emoji_img_data, view_kw, nick, nd, + ) -> Optional[discord.Message]: + """Send via webhook with automatic retry/fallback.""" + files = [] + for a in message.attachments: + try: + files.append(await a.to_file()) + except Exception: + pass + files.extend(emoji_files) + all_embeds = (extra_embeds or []) + emoji_embeds + send_content = content if content else None + if not send_content and not files and not all_embeds: + send_content = "*[empty message]*" + + try: + wh = await self._wh(ch) + return await wh.send( + content=send_content, + username=truncate(uname, 80), + avatar_url=avatar, + files=files or discord.utils.MISSING, + embeds=all_embeds[:10] or discord.utils.MISSING, + wait=True, + **view_kw, + ) + except (discord.NotFound, discord.InvalidData): + # Webhook stale — retry with fresh one + try: + wh = await self._wh(ch, force_refresh=True) + files2 = [] + for a in message.attachments: + try: + files2.append(await a.to_file()) + except Exception: + pass + ee2, ef2 = build_emoji_embeds_and_files(emoji_img_data) + files2.extend(ef2) + all_embeds2 = (extra_embeds or []) + ee2 + send_content2 = content if content else None + if not send_content2 and not files2 and not all_embeds2: + send_content2 = "*[empty message]*" + return await wh.send( + content=send_content2, + username=truncate(uname, 80), + avatar_url=avatar, + files=files2 or discord.utils.MISSING, + embeds=all_embeds2[:10] or discord.utils.MISSING, + wait=True, + **view_kw, + ) + except Exception: + log.warning("Webhook retry failed for %s, falling back to embed", ch.id) + em = build_relay_embed(message, nick, nd.get("colour")) + return await ch.send(embeds=[em] + (extra_embeds or [])[:9], **view_kw) + except discord.Forbidden: + log.warning("No webhook perms in %s, falling back to embed", ch.id) + em = build_relay_embed(message, nick, nd.get("colour")) + return await ch.send(embeds=[em] + (extra_embeds or [])[:9], **view_kw) diff --git a/wormhole/listeners/sync.py b/wormhole/listeners/sync.py new file mode 100644 index 0000000..9489401 --- /dev/null +++ b/wormhole/listeners/sync.py @@ -0,0 +1,227 @@ +""" +Sync listeners — edit, delete, reaction add/remove, starboard, karma. +""" + +from __future__ import annotations + +import logging + +import discord +from redbot.core import commands + +from ..utils import build_star_embed, truncate, warn_embed + +log = logging.getLogger("red.evecogs.wormhole") + + +class SyncListener: + """Mixin — edit/delete/reaction sync listeners.""" + + # ── Edit sync ────────────────────────────────────────────────────────── + + @commands.Cog.listener() + async def on_message_edit(self, before: discord.Message, after: discord.Message) -> None: + await self._ready.wait() + if not after.guild or after.author.bot or before.content == after.content: + return + eff = after.channel.id + if isinstance(after.channel, discord.Thread): + eff = after.channel.parent_id + net = await self._net_for_ch(eff) + if not net: + return + nd = await self._net(net) + if not nd or not nd.get("sync_edits"): + return + mapping = self.msg_map.get_relayed(net, after.id) + if not mapping: + return + mode = nd.get("relay_mode", "webhook") + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: + continue + try: + cm = self._get_override(nd, ch_id, "relay_mode") or mode + if cm == "webhook": + wh = await self._wh(ch) + await wh.edit_message(mid, content=after.content or "") + elif cm == "embed": + msg = await ch.fetch_message(mid) + if msg.embeds: + e = msg.embeds[0] + e.description = after.content or "*[no text]*" + await msg.edit(embed=e) + else: + msg = await ch.fetch_message(mid) + cp = msg.content.find(":** ") + if cp != -1: + await msg.edit(content=truncate(msg.content[: cp + 4] + after.content, 2000)) + except Exception: + pass + + # ── Delete sync ──────────────────────────────────────────────────────── + + @commands.Cog.listener() + async def on_message_delete(self, message: discord.Message) -> None: + await self._ready.wait() + if not message.guild or message.author.bot: + return + eff = message.channel.id + if isinstance(message.channel, discord.Thread): + eff = message.channel.parent_id + net = await self._net_for_ch(eff) + if not net: + return + nd = await self._net(net) + if not nd or not nd.get("sync_deletes"): + return + mapping = self.msg_map.get_relayed(net, message.id) + if not mapping: + return + mode = nd.get("relay_mode", "webhook") + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: + continue + try: + cm = self._get_override(nd, ch_id, "relay_mode") or mode + if cm == "webhook": + wh = await self._wh(ch) + await wh.delete_message(mid) + else: + msg = await ch.fetch_message(mid) + await msg.delete() + except Exception: + pass + + # ── Reaction add — sync + karma + starboard ─────────────────────────── + + @commands.Cog.listener() + async def on_reaction_add(self, reaction: discord.Reaction, user: discord.User) -> None: + await self._ready.wait() + if user.bot: + return + msg = reaction.message + if not msg.guild: + return + eff = msg.channel.id + if isinstance(msg.channel, discord.Thread): + eff = msg.channel.parent_id + net = await self._net_for_ch(eff) + if not net: + return + nd = await self._net(net) + if not nd: + return + + # Reaction sync + if nd.get("sync_reactions"): + mapping = self.msg_map.get_relayed(net, msg.id) + if mapping: + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: + continue + try: + rm = await ch.fetch_message(mid) + await rm.add_reaction(reaction.emoji) + except Exception: + pass + + # Karma + if nd.get("karma_enabled") and str(reaction.emoji) == nd.get("karma_emoji", "👍"): + target_msg = msg + if target_msg.author.id != user.id: + uid = str(target_msg.author.id) + async with self.config.networks() as ns: + if net in ns: + ns[net].setdefault("karma_scores", {}) + ns[net]["karma_scores"][uid] = ns[net]["karma_scores"].get(uid, 0) + 1 + + # Starboard + if nd.get("starboard_enabled") and str(reaction.emoji) == "⭐": + threshold = nd.get("starboard_threshold", 3) + star_ch_id = nd.get("starboard_channel") + if not star_ch_id: + return + star_ch = self.bot.get_channel(star_ch_id) + if not star_ch: + return + + total_stars = 0 + for r in msg.reactions: + if str(r.emoji) == "⭐": + total_stars += r.count + + if total_stars >= threshold: + starred = nd.get("starred_messages", {}) + msg_key = str(msg.id) + img_url = None + if msg.attachments: + for a in msg.attachments: + if a.content_type and a.content_type.startswith("image/"): + img_url = a.url + break + em = build_star_embed( + msg.author.display_name, + msg.author.display_avatar.url, + msg.content or "*[no text]*", + total_stars, + msg.guild.name, + msg.channel.name, + img_url, + ) + + if msg_key in starred: + try: + board_msg = await star_ch.fetch_message(starred[msg_key]["board_msg_id"]) + await board_msg.edit(embed=em) + async with self.config.networks() as ns: + if net in ns: + ns[net]["starred_messages"][msg_key]["stars"] = total_stars + except Exception: + pass + else: + try: + board_msg = await star_ch.send(embed=em) + async with self.config.networks() as ns: + if net in ns: + ns[net].setdefault("starred_messages", {})[msg_key] = { + "stars": total_stars, + "board_msg_id": board_msg.id, + } + except Exception: + pass + + # ── Reaction remove sync ─────────────────────────────────────────────── + + @commands.Cog.listener() + async def on_reaction_remove(self, reaction: discord.Reaction, user: discord.User) -> None: + await self._ready.wait() + if user.bot: + return + msg = reaction.message + if not msg.guild: + return + eff = msg.channel.id + if isinstance(msg.channel, discord.Thread): + eff = msg.channel.parent_id + net = await self._net_for_ch(eff) + if not net: + return + nd = await self._net(net) + if not nd or not nd.get("sync_reactions"): + return + mapping = self.msg_map.get_relayed(net, msg.id) + if not mapping: + return + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: + continue + try: + rm = await ch.fetch_message(mid) + await rm.remove_reaction(reaction.emoji, self.bot.user) + except Exception: + pass diff --git a/wormhole/models/__init__.py b/wormhole/models/__init__.py new file mode 100644 index 0000000..289193b --- /dev/null +++ b/wormhole/models/__init__.py @@ -0,0 +1,16 @@ +"""Data models for Wormhole — config defaults, permissions, message mapping.""" + +from .config import DEFAULT_NETWORK, DEFAULT_GLOBAL, MAP_LIMIT +from .message_map import MessageMap +from .permissions import Role, has_role, requires_role, role_name + +__all__ = [ + "DEFAULT_NETWORK", + "DEFAULT_GLOBAL", + "MAP_LIMIT", + "MessageMap", + "Role", + "has_role", + "requires_role", + "role_name", +] diff --git a/wormhole/models/config.py b/wormhole/models/config.py new file mode 100644 index 0000000..7afad87 --- /dev/null +++ b/wormhole/models/config.py @@ -0,0 +1,235 @@ +""" +Default configuration structures for Wormhole networks. + +All per-network data lives inside ``networks.`` in Red's Config. +Global data (cross-network) lives at the top level. +""" + +from __future__ import annotations + +DEFAULT_NETWORK: dict = { + # ── Ownership / staff ────────────────────────────────────────────────── + "owner_id": 0, + "staff": {}, # {user_id_str: Role int} (new hierarchy) + "staff_ids": [], # LEGACY — flat list, migrated on read + + # ── Channels ─────────────────────────────────────────────────────────── + "channels": [], + + # ── Identity ─────────────────────────────────────────────────────────── + "use_webhooks": True, + "relay_mode": "webhook", # webhook | embed | compact + "image_mode": "user", # user | server | custom + "name_mode": "both", # user | server | both | custom + "custom_icon": None, + "custom_name": None, + "colour": None, + "description": "", + + # ── Moderation ───────────────────────────────────────────────────────── + "banned_users": [], + "banned_servers": [], + "muted_users": [], + "muted_servers": [], + "word_filters": [], + "regex_filters": [], + "allowlist_servers": [], + + # ── Feature toggles ──────────────────────────────────────────────────── + "sync_edits": True, + "sync_deletes": True, + "sync_reactions": True, + "sync_replies": True, + "sync_threads": False, + "sync_stickers": True, + "sync_pins": False, + "forward_embeds": True, + "nsfw_gate": True, + "silent": False, + "frozen": False, + + # ── Rate limit ───────────────────────────────────────────────────────── + "rate_limit_rate": 5, + "rate_limit_per": 10.0, + + # ── Logging ──────────────────────────────────────────────────────────── + "log_channel": None, + + # ── Server nicknames ─────────────────────────────────────────────────── + "server_nicknames": {}, + + # ── Stats ────────────────────────────────────────────────────────────── + "total_messages": 0, + "created_at": None, + + # ── Auto-moderation ──────────────────────────────────────────────────── + "automod": { + "enabled": False, + "anti_spam": False, + "anti_mention_spam": False, + "anti_caps": False, + "anti_invite": False, + "anti_link": False, + "anti_zalgo": False, + "anti_spoiler": False, + "anti_emote_spam": False, + "anti_newline_spam": False, + "anti_raid": False, + "max_mentions": 5, + "caps_threshold": 0.7, + "spam_window": 30.0, + "spam_threshold": 3, + "max_emotes": 10, + "max_newlines": 15, + "raid_window": 60.0, + "raid_threshold": 10, + }, + + # ── Invites ──────────────────────────────────────────────────────────── + "invites": {}, + "vanity_invite": None, + + # ── Portal / welcome ─────────────────────────────────────────────────── + "portal_messages": {}, + "welcome_message": "", + + # ── Mention control (legacy) ─────────────────────────────────────────── + "mention_control": { + "strip_everyone": True, + "strip_role_mentions": False, + "strip_user_mentions": False, + }, + + # ── User profiles ────────────────────────────────────────────────────── + "user_profiles": {}, + "blackout_schedules": [], + + # ── DM relay ─────────────────────────────────────────────────────────── + "dm_enabled": False, + "dm_subscribers": [], + "dm_relay_mode": "embed", # embed | compact | plain + + # ── Starboard ────────────────────────────────────────────────────────── + "starboard_enabled": False, + "starboard_channel": None, + "starboard_threshold": 3, + "starred_messages": {}, + + # ── Audit log ────────────────────────────────────────────────────────── + "audit_log": [], + + # ── Attachment filters ───────────────────────────────────────────────── + "blocked_extensions": [], + "max_filesize": None, + + # ── Karma ────────────────────────────────────────────────────────────── + "karma_enabled": False, + "karma_emoji": "👍", + "karma_scores": {}, + + # ── MOTD / Rules ─────────────────────────────────────────────────────── + "motd": "", + "rules": "", + + # ── Relay delay ──────────────────────────────────────────────────────── + "relay_delay": 0, + + # ── Highlights ───────────────────────────────────────────────────────── + "highlights": {}, + + # ── Network roles (legacy, unused) ───────────────────────────────────── + "roles": {}, + + # ── Per-channel overrides ────────────────────────────────────────────── + "channel_overrides": {}, + + # ── Scheduled messages ───────────────────────────────────────────────── + "scheduled_messages": [], + + # ── Slowmode ─────────────────────────────────────────────────────────── + "slowmode": 0, + + # ── Discovery ────────────────────────────────────────────────────────── + "public": False, + "tags": [], + + # ── Typing indicator relay ───────────────────────────────────────────── + "sync_typing": False, + + # ── Anonymous mode ───────────────────────────────────────────────────── + "anonymous": False, + "anon_salt": "", + + # ── Mirror channels ──────────────────────────────────────────────────── + "mirror_channels": [], + + # ── Ephemeral messages ───────────────────────────────────────────────── + "ephemeral_delay": 0, + + # ── Auto-responses ───────────────────────────────────────────────────── + "auto_responses": {}, + + # ── Media-only mode ──────────────────────────────────────────────────── + "media_only": False, + + # ── Analytics ────────────────────────────────────────────────────────── + "analytics": { + "hourly": {}, + "top_users": {}, + }, + + # ── Health ───────────────────────────────────────────────────────────── + "last_health_check": None, + "unhealthy_channels": [], + + # ── Polls ────────────────────────────────────────────────────────────── + "active_polls": {}, + + # ── AFK system ───────────────────────────────────────────────────────── + "afk_users": {}, + + # ── Personal ignore list ─────────────────────────────────────────────── + "user_ignores": {}, + + # ── User colours ─────────────────────────────────────────────────────── + "user_colours": {}, + + # ── Quiet hours ──────────────────────────────────────────────────────── + "quiet_hours": {}, + + # ── Network bridging ─────────────────────────────────────────────────── + "bridge_from": [], + "bridge_to": [], + + # ── Granular mention policy ──────────────────────────────────────────── + "mention_policy": { + "allow_user_mentions": False, + "allow_role_mentions": False, + "allow_everyone": False, + "allow_here": False, + }, + "server_mention_overrides": {}, + "mention_exempt_users": [], + "mention_optout_users": [], + + # ── ToS acceptance gate ──────────────────────────────────────────────── + "rules_required": False, + "rules_text": "", + "rules_accepted": {}, + + # ── Report system ────────────────────────────────────────────────────── + "reports": [], + "report_counter": 0, +} + + +DEFAULT_GLOBAL: dict = { + "networks": {}, + "max_networks_per_user": 10, + "global_banned_users": [], + "global_banned_servers": [], + "bookmarks": {}, +} + + +MAP_LIMIT: int = 2_000 diff --git a/wormhole/models/message_map.py b/wormhole/models/message_map.py new file mode 100644 index 0000000..f72574f --- /dev/null +++ b/wormhole/models/message_map.py @@ -0,0 +1,54 @@ +""" +In-memory bidirectional message map for tracking relayed messages. + +Maps original message IDs ↔ relayed copies across channels within a network. +Capped at MAP_LIMIT entries per direction to bound memory usage. +""" + +from __future__ import annotations + +from collections import OrderedDict +from typing import Dict, List, Optional + +from .config import MAP_LIMIT + + +class MessageMap: + """Bidirectional map: original_id ↔ {channel_id: relayed_msg_id}. + + ``forward[net][original_id] = {ch_id: relayed_id, ...}`` + ``reverse[net][relayed_id] = original_id`` + """ + + def __init__(self) -> None: + self.forward: Dict[str, OrderedDict[int, Dict[int, int]]] = {} + self.reverse: Dict[str, OrderedDict[int, int]] = {} + + def add(self, network: str, original_id: int, mapping: Dict[int, int]) -> None: + """Record that *original_id* was relayed to *mapping* channels.""" + fwd = self.forward.setdefault(network, OrderedDict()) + rev = self.reverse.setdefault(network, OrderedDict()) + + fwd[original_id] = mapping + for relayed_id in mapping.values(): + rev[relayed_id] = original_id + + # Prune oldest entries + while len(fwd) > MAP_LIMIT: + _old_orig, old_map = fwd.popitem(last=False) + for rid in old_map.values(): + rev.pop(rid, None) + while len(rev) > MAP_LIMIT * 2: + rev.popitem(last=False) + + def get_relayed(self, network: str, original_id: int) -> Dict[int, int]: + """Get ``{ch_id: relayed_id}`` for an original message.""" + return self.forward.get(network, {}).get(original_id, {}) + + def get_original(self, network: str, relayed_id: int) -> Optional[int]: + """Get the original message ID from a relayed copy ID.""" + return self.reverse.get(network, {}).get(relayed_id) + + def get_all_relayed_ids(self, network: str, original_id: int) -> List[int]: + """Get all relayed message IDs for an original.""" + return list(self.get_relayed(network, original_id).values()) diff --git a/wormhole/models/permissions.py b/wormhole/models/permissions.py new file mode 100644 index 0000000..bb63a8f --- /dev/null +++ b/wormhole/models/permissions.py @@ -0,0 +1,162 @@ +""" +Per-network hierarchical permission system for Wormhole. + +Roles are scoped to individual networks — a user can be Admin in one +network and have no role in another. Bot owners (Red's built-in +``bot.is_owner()``) bypass all checks globally. + +Hierarchy (highest → lowest): + Owner → Admin → Moderator → Helper → Member + +Higher roles can assign any role below them. +""" + +from __future__ import annotations + +import enum +import functools +from typing import Optional + +import discord +from redbot.core import commands + + +class Role(enum.IntEnum): + """Network permission tiers. Higher value = more power.""" + + MEMBER = 0 + HELPER = 1 + MODERATOR = 2 + ADMIN = 3 + OWNER = 4 + + +_ROLE_NAMES = { + Role.OWNER: "Owner", + Role.ADMIN: "Admin", + Role.MODERATOR: "Moderator", + Role.HELPER: "Helper", + Role.MEMBER: "Member", +} + +_ROLE_LOOKUP = {v.lower(): k for k, v in _ROLE_NAMES.items()} + + +def role_name(role: Role) -> str: + """Human-friendly name for a role.""" + return _ROLE_NAMES.get(role, "Unknown") + + +def role_from_str(name: str) -> Optional[Role]: + """Parse a role name (case-insensitive). Returns ``None`` on failure.""" + return _ROLE_LOOKUP.get(name.strip().lower()) + + +def _get_user_role(net_data: dict, user_id: int) -> Role: + """Determine a user's role in a network from its config dict.""" + if user_id == net_data.get("owner_id"): + return Role.OWNER + staff = net_data.get("staff", {}) + uid_str = str(user_id) + if uid_str in staff: + return Role(staff[uid_str]) + # Legacy flat list migration: treat old staff_ids entries as Admin + if user_id in net_data.get("staff_ids", []): + return Role.ADMIN + return Role.MEMBER + + +def has_role(net_data: dict, user_id: int, minimum: Role) -> bool: + """Check whether *user_id* meets *minimum* role in this network.""" + return _get_user_role(net_data, user_id) >= minimum + + +def get_role(net_data: dict, user_id: int) -> Role: + """Return the user's role in a network.""" + return _get_user_role(net_data, user_id) + + +def can_assign(actor_role: Role, target_role: Role) -> bool: + """Whether *actor_role* is allowed to assign/revoke *target_role*.""" + return actor_role > target_role + + +def set_staff_role(net_data: dict, user_id: int, role: Role) -> None: + """Assign *role* to *user_id* in the network config (mutates in place). + + Setting ``Role.MEMBER`` removes the user from the staff dict. + """ + staff = net_data.setdefault("staff", {}) + uid_str = str(user_id) + if role <= Role.MEMBER: + staff.pop(uid_str, None) + else: + staff[uid_str] = int(role) + + +def remove_staff(net_data: dict, user_id: int) -> None: + """Remove a user from network staff entirely.""" + set_staff_role(net_data, user_id, Role.MEMBER) + + +def list_staff(net_data: dict) -> dict[int, Role]: + """Return ``{user_id: Role}`` for all staff (including owner).""" + result: dict[int, Role] = {} + owner = net_data.get("owner_id") + if owner: + result[owner] = Role.OWNER + for uid_str, level in net_data.get("staff", {}).items(): + try: + result[int(uid_str)] = Role(level) + except (ValueError, KeyError): + pass + # Legacy migration + for uid in net_data.get("staff_ids", []): + if uid not in result: + result[uid] = Role.ADMIN + return result + + +# ── Decorator for commands ───────────────────────────────────────────────── + +def requires_role(minimum: Role): + """Decorator factory — the command must supply *name* as first arg. + + Usage inside a cog:: + + @commands.command() + @requires_role(Role.MODERATOR) + async def wh_mod_ban(self, ctx, name: str, user: discord.User): + ... + + The decorator fetches the network data for *name*, checks the + invoker's role, and short-circuits with an error embed if they + lack permission. Bot owners always pass. + """ + + def decorator(func): + @functools.wraps(func) + async def wrapper(self, ctx: commands.Context, name: str, *args, **kwargs): + # Bot owner bypasses everything + if await self.bot.is_owner(ctx.author): + return await func(self, ctx, name, *args, **kwargs) + # Fetch network + nd = await self._net(name) + if nd is None: + from ..utils import err_embed + await ctx.send(embed=err_embed(f"Network `{name}` not found.")) + return + if not has_role(nd, ctx.author.id, minimum): + from ..utils import err_embed + await ctx.send( + embed=err_embed( + f"You need at least **{role_name(minimum)}** " + f"role in `{name}` to use this command." + ) + ) + return + return await func(self, ctx, name, *args, **kwargs) + + return wrapper + + return decorator diff --git a/wormhole/services/__init__.py b/wormhole/services/__init__.py new file mode 100644 index 0000000..2e4ca1a --- /dev/null +++ b/wormhole/services/__init__.py @@ -0,0 +1 @@ +"""Background services — automod, emoji relay, analytics, task loops.""" diff --git a/wormhole/services/emoji.py b/wormhole/services/emoji.py new file mode 100644 index 0000000..cfa82f2 --- /dev/null +++ b/wormhole/services/emoji.py @@ -0,0 +1,78 @@ +""" +Foreign emoji relay — resolve custom emojis the bot can't see. + +Unknown emojis are replaced with ``:name:`` in text and their images +are downloaded from the CDN, then shown as embed thumbnails (~80×80 px) +via the ``attachment://`` scheme. +""" + +from __future__ import annotations + +import io +import re +from typing import TYPE_CHECKING + +import discord + +if TYPE_CHECKING: + from redbot.core.bot import Red + +_CUSTOM_EMOJI_RE = re.compile(r"<(a?):(\w+):(\d+)>") +_EMOJI_IMAGE_LIMIT = 5 # max emoji thumbnails per message + + +async def resolve_foreign_emojis( + bot: Red, content: str +) -> tuple[str, list[tuple[str, bytes]]]: + """Replace unknown custom emojis with ``:name:`` and fetch small images. + + Returns ``(new_content, [(filename, image_bytes), ...])``. + """ + unknown: list[tuple[str, int, str]] = [] + + def _check(m: re.Match) -> str: + animated, name, eid = m.group(1), m.group(2), int(m.group(3)) + if bot.get_emoji(eid): + return m.group(0) + ext = "gif" if animated else "png" + unknown.append((name, eid, ext)) + return f":{name}:" + + new_content = _CUSTOM_EMOJI_RE.sub(_check, content) + + emoji_data: list[tuple[str, bytes]] = [] + if unknown: + import aiohttp + + async with aiohttp.ClientSession() as session: + for name, eid, ext in unknown[:_EMOJI_IMAGE_LIMIT]: + try: + url = f"https://cdn.discordapp.com/emojis/{eid}.{ext}?size=48" + async with session.get( + url, timeout=aiohttp.ClientTimeout(total=5) + ) as resp: + if resp.status == 200: + emoji_data.append((f"{name}.{ext}", await resp.read())) + except Exception: + pass + + return new_content, emoji_data + + +def build_emoji_embeds_and_files( + emoji_data: list[tuple[str, bytes]], +) -> tuple[list[discord.Embed], list[discord.File]]: + """Build embed-thumbnail pairs for foreign emoji images. + + Each unknown emoji gets a tiny embed whose thumbnail references the + attached file via ``attachment://``. Thumbnails render at ~80×80 px. + """ + embeds: list[discord.Embed] = [] + files: list[discord.File] = [] + for fname, fdata in emoji_data: + f = discord.File(io.BytesIO(fdata), filename=fname) + em = discord.Embed() + em.set_thumbnail(url=f"attachment://{fname}") + files.append(f) + embeds.append(em) + return embeds, files diff --git a/wormhole/ui/__init__.py b/wormhole/ui/__init__.py new file mode 100644 index 0000000..1288445 --- /dev/null +++ b/wormhole/ui/__init__.py @@ -0,0 +1 @@ +"""Discord UI components — modals, views, context menus.""" diff --git a/wormhole/ui/modals.py b/wormhole/ui/modals.py new file mode 100644 index 0000000..49348c9 --- /dev/null +++ b/wormhole/ui/modals.py @@ -0,0 +1,75 @@ +"""Discord UI modals for Wormhole (e.g. report form).""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +import discord + +from ..utils import ok_embed, warn_embed + +if TYPE_CHECKING: + from ..core import Wormhole + + +class ReportModal(discord.ui.Modal, title="Report Message"): + reason = discord.ui.TextInput( + label="Reason", + style=discord.TextStyle.paragraph, + placeholder="Describe why you're reporting this message...", + required=True, + max_length=1000, + ) + + def __init__(self, cog: Wormhole, net_name: str, message: discord.Message) -> None: + super().__init__() + self.cog = cog + self.net_name = net_name + self.message = message + + async def on_submit(self, interaction: discord.Interaction) -> None: + nets = await self.cog.config.networks() + nd = nets.get(self.net_name) + if not nd: + await interaction.response.send_message("Network not found.", ephemeral=True) + return + + report_id = nd.get("report_counter", 0) + 1 + report = { + "id": report_id, + "reporter_id": interaction.user.id, + "author_id": self.message.author.id, + "channel_id": self.message.channel.id, + "guild_id": self.message.guild.id if self.message.guild else None, + "message_content_hash": str(hash(self.message.content or "")), + "reason": self.reason.value, + "timestamp": datetime.now(timezone.utc).isoformat(), + "resolved": False, + "resolved_by": None, + } + + async with self.cog.config.networks() as ns: + if self.net_name in ns: + ns[self.net_name].setdefault("reports", []).append(report) + ns[self.net_name]["report_counter"] = report_id + + owner = self.cog.bot.get_user(nd.get("owner_id")) + if owner: + try: + await owner.send( + embed=warn_embed( + f"**Report #{report_id}** in `{self.net_name}`\n" + f"Reporter: {interaction.user} (`{interaction.user.id}`)\n" + f"Author: {self.message.author} (`{self.message.author.id}`)\n" + f"Reason: {self.reason.value}", + title="🚩 New Report", + ) + ) + except discord.Forbidden: + pass + + await interaction.response.send_message( + embed=ok_embed(f"Report #{report_id} submitted. Staff will review it."), + ephemeral=True, + ) diff --git a/wormhole/ui/views.py b/wormhole/ui/views.py new file mode 100644 index 0000000..dd5e65e --- /dev/null +++ b/wormhole/ui/views.py @@ -0,0 +1,16 @@ +"""Persistent and ephemeral views for Wormhole messages.""" + +from __future__ import annotations + +import discord + + +def reply_jump_view(url: str) -> discord.ui.View: + """Create a View with a single URL button pointing to the replied message.""" + view = discord.ui.View() + view.add_item(discord.ui.Button( + style=discord.ButtonStyle.link, + label="Jump to replied message", + url=url, + )) + return view