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