Compare commits

..

1 commit

Author SHA1 Message Date
Viktor
2df0d504d0 docs: add per-cog README files for all 8 cogs
Generated comprehensive README documentation for each cog:
- dashboard (EveDash): Web dashboard setup & commands
- newhelpmenu: Components V2 help system
- nexuscore: All-in-one management (tickets, mod, economy, etc.)
- selfbotguard: 14-layer selfbot detection
- serverwhitelist: Server join management
- userslash: User-installable slash commands
- voicemeister: Temporary voice channels
- wormhole: Cross-server relay (100+ commands)

Each README includes: features, installation, commands, data statement.
2026-05-14 20:38:04 +00:00
60 changed files with 5809 additions and 8815 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

113
README.md
View file

@ -136,119 +136,6 @@ Connect Discord channels across unlimited servers into named networks. All comma
---
# GhostWipe
**Auto-deletes a departing member's messages server-wide, with a full HTML audit-log viewer.**
Fires only on `on_member_remove` — covering voluntary leaves, kicks, and bans alike (Discord delivers all three through that single event). GhostWipe then classifies *why* the member left via the audit log, purges every message they ever sent across text channels, voice-channel chats, and threads (including archived ones), and produces a complete audit trail: a summary embed plus a self-contained, dark/light-themed HTML report with sidebar channel switching, search, and attachment previews.
## Feature Highlights
| Category | Features |
|----------|----------|
| **Deletion** | Server-wide purge across text channels, voice chats, active + archived threads |
| **Triggers** | Independently toggle leave / kick / ban as purge triggers |
| **Exemptions** | Whitelist specific users, ignore specific roles or channels |
| **Safety** | Dry-run/simulation mode, grace-period delay that auto-cancels on rejoin, rate-limited scanning |
| **Reporting** | Self-contained HTML viewer (channel switching, search, attachment previews), per-channel breakdown embed, content redaction for privacy, configurable retention |
| **Review** | `history` and `viewreport` to revisit and re-send past purge events, lifetime `stats` |
| **Manual** | `purge` command to backfill a departure GhostWipe missed |
## Command Reference (`[p]ghostwipe`, aliases `gw`/`wipe`)
| Command | Description |
|---------|-------------|
| `enable` / `disable` | Toggle GhostWipe for this server |
| `logchannel [#channel]` | Set/clear where purge reports are posted |
| `trigger <leave\|kick\|ban> <bool>` | Toggle which departure types trigger a purge |
| `whitelist add/remove/list` | Users who are never purged |
| `ignorerole add/remove/list` | Roles that exempt a member from being purged |
| `ignorechannel add/remove/list` | Channels/threads never scanned |
| `scanlimit <messages\|none>` | Per-channel scan depth (or unlimited) |
| `threads` / `archivedthreads` | Toggle scanning active/archived threads |
| `dryrun <bool>` | Simulate and report without deleting |
| `delay <seconds>` | Grace period before purging (cancelled if the member rejoins) |
| `report content/attach <bool>` | Redact report content / attach the HTML file |
| `retention <days>` | Auto-delete saved reports after N days (`0` = forever) |
| `settings` | Full configuration summary |
| `history [page]` | Recent purge events |
| `viewreport <event_id>` | Re-send a past report |
| `stats` | Lifetime purge counters |
| `purge <user> [confirm]` | Manually trigger a purge |
---
# Affiliates
**Ping-on-Join, a persistent affiliate-server board, and a separate DM-affiliates list — all in one cog.**
**POJ (Ping on Join)** mentions new human members in configured channels, then auto-deletes the ping after a few seconds — enough to trigger a push notification without leaving clutter behind. **Affiliates** is a staff-configured channel holding a persistent, auto-updating plain-text board listing partner servers (name + invite), 10 per message and numbered 1..x, managed entirely through Discord UI — no raw IDs to remember, no manual message editing. **DM Affiliates** mirrors the same add/remove/list management on a completely separate list that isn't posted anywhere; instead every new member gets it as a DM the moment they join.
## Feature Highlights
| Category | Features |
|----------|----------|
| **POJ** | Multiple configurable ping channels, configurable delay (1–60s), configurable `{member}` message template |
| **Affiliate Board** | Modal-based add (Server Name + Server Invite), select-menu + confirmation removal, always-contiguous 1..x numbering, 10 entries per plain-text message (a new message per additional 10, up to 100 total) |
| **Persistence** | Each board page is stored by message ID and automatically recreated if deleted — survives every bot restart |
| **DM Affiliates** | Identical add/remove/list management on a fully separate list, sent as a DM on join, independently toggleable |
| **Access** | Management commands are admin-only; `list` previews are open to any member |
## Command Reference
| Command | Description |
|---------|-------------|
| `[p]poj enable` / `disable` | Toggle Ping on Join |
| `[p]poj channel add/remove/list` | Channels that ping new members |
| `[p]poj delay <seconds>` | Delete-delay for the ping |
| `[p]poj message <template>` | Ping message template (must include `{member}`) |
| `[p]aff channel #channel` | Set the affiliate board channel |
| `[p]aff add` | Add an affiliate (button → modal) |
| `[p]aff remove` | Remove an affiliate (select → confirm) |
| `[p]aff list` | Preview the current board |
| `[p]aff refresh` | Force-recreate the persistent message |
| `[p]aff dm add/remove/list` | Same management, separate DM-only list |
| `[p]aff dm enable/disable` | Toggle DMing new members the list |
---
# ImageVault
**Auto-deletes posted images and re-hosts them in a private vault channel so they can be retrieved on request.**
Watches configured channels for image attachments, silently reposts each image (plus the message's text and full attribution) into a staff-only vault channel, then deletes the original — keeping channels clean without anything actually being lost. Because images are re-hosted on Discord rather than linked directly, there's no link-expiry problem: retrieval always fetches a fresh copy straight from the vault message. Spoilered images stay spoilered when they're sent back.
## Feature Highlights
| Category | Features |
|----------|----------|
| **Archiving** | Re-uploads images into a private vault channel before deleting the original; preserves spoiler tags round-trip |
| **Scope** | Watch specific channels (or everything), ignore specific channels, ignore bots/webhooks, max file size |
| **Behavior** | Archive-only mode (skip deletion), optional text-content repost after removal |
| **Retrieval** | `show`/`get <id>` re-sends the image; permission modes: anyone / original poster / staff only |
| **Browsing** | `list` and `mine` to browse the archive, `forget` to drop an index entry |
## Command Reference (`[p]imagevault`, aliases `iv`/`vault`)
| Command | Description |
|---------|-------------|
| `enable` / `disable` | Toggle ImageVault (requires a vault channel first) |
| `vaultchannel #channel` | Set the private channel images get archived into |
| `watchchannel add/remove/list` | Channels to watch (empty = all channels) |
| `ignorechannel add/remove/list` | Channels to never touch |
| `deleteoriginal <bool>` | Toggle actually deleting the original vs. archive-only |
| `repost <bool>` | Toggle reposting text content after an image is removed |
| `ignorebots <bool>` | Toggle processing bot/webhook messages |
| `maxsize <mb>` | Max image size to archive |
| `retrieve <anyone\|author\|staff>` | Who can retrieve archived images |
| `settings` | Full configuration summary |
| `show`/`get <id>` | Retrieve an archived image |
| `list [page]` / `mine [page]` | Browse the archive |
| `forget <id>` | Drop an index entry (staff only) |
| `stats` | Lifetime archive count |
---
## Architecture
```

View file

@ -1,20 +0,0 @@
"""
Affiliates - Ping on Join (POJ), a persistent affiliate-server board, and a
separate DM-affiliates list sent to new members on join.
"""
from redbot.core.bot import Red
from .affiliates import Affiliates
__red_end_user_data_statement__ = (
"This cog stores configuration data (POJ channels/settings, the affiliate board channel, and the "
"affiliate/DM-affiliate entries themselves) per server. Each affiliate entry records the server name, "
"invite link, the Discord user ID of whoever added it, and a timestamp. This data is not sent anywhere "
"off the host running the bot."
)
async def setup(bot: Red) -> None:
await bot.add_cog(Affiliates(bot))

View file

@ -1,469 +0,0 @@
"""
Affiliates - Ping on Join (POJ), a persistent affiliate-server board, and a
separate DM-affiliates list sent to new members on join.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Union
import discord
from redbot.core import Config, commands
from redbot.core.bot import Red
from .views import AddAffiliateButtonView, RemoveSelectView, ok_embed, info_embed, err_embed
LOG = logging.getLogger("red.evecogs.affiliates")
COLOUR_INFO = discord.Colour.blurple()
LIST_KEYS = {"aff": "affiliates", "dm": "dm_affiliates"}
NEXT_ID_KEYS = {"aff": "aff_next_id", "dm": "dm_next_id"}
MAX_ENTRIES = 100 # 10 messages x 10 entries/message
def _chunk(items: List[dict], size: int = 10) -> List[List[dict]]:
return [items[i:i + size] for i in range(0, len(items), size)]
def render_pages(entries: List[dict], guild_name: str) -> List[str]:
"""Render entries into plain-text message bodies, 10 per message.
Deliberately plain text, not embeds/Components V2: a LayoutView's real
budget (verified against the installed discord.py — 40 total children,
4000 total text characters across the *whole* view) can't safely hold
even one 10-entry page in the worst case, and Discord's own 2000-char
message-content cap is what actually bounds this — see AffiliateModal's
max_length comment for the field-length math that keeps every page safe.
"""
header = f"**{guild_name}'s Affiliations:**"
if not entries:
return [f"{header}\n\nNothing here yet."]
chunks = _chunk(entries, 10)[:10] # cap at 10 pages = 100 entries
pages = []
for page_idx, chunk in enumerate(chunks):
blocks = [header] if page_idx == 0 else []
for i, entry in enumerate(chunk):
position = page_idx * 10 + i + 1
blocks.append(f"{position}. {entry['name']} __==__~~--~~ {entry['invite']}")
pages.append("\n\n".join(blocks))
return pages
class Affiliates(commands.Cog):
"""POJ (Ping on Join), an affiliate-server board, and DM affiliates."""
__version__ = "1.0.0"
__author__ = ["everestmcarthur"]
def __init__(self, bot: Red):
self.bot = bot
self.config = Config.get_conf(self, identifier=0x6166_6669_6C69_6174_6573, force_registration=True)
default_guild: Dict[str, Any] = {
# POJ
"poj_enabled": False,
"poj_channels": [],
"poj_delay": 5,
"poj_message": "{member}",
# Affiliates (channel board)
"aff_channel": None,
"aff_message_ids": [], # one message ID per 10-entry page
"aff_next_id": 1,
"affiliates": [],
# DM Affiliates (separate list, no channel)
"dm_enabled": False,
"dm_next_id": 1,
"dm_affiliates": [],
}
self.config.register_guild(**default_guild)
# ══════════════════════════════════════════════════════════
# Shared storage helpers (used by both commands and views.py)
# ══════════════════════════════════════════════════════════
async def add_entry(
self, guild_id: int, list_kind: str, name: str, invite: str, added_by: int
) -> Optional[int]:
"""Append an entry; returns its 1-indexed display position, or None if full."""
list_key = LIST_KEYS[list_kind]
id_key = NEXT_ID_KEYS[list_kind]
guild_conf = self.config.guild_from_id(guild_id)
async with guild_conf.all() as conf:
entries = conf[list_key]
if len(entries) >= MAX_ENTRIES:
return None
entry_id = conf[id_key]
conf[id_key] = entry_id + 1
entries.append({
"id": entry_id,
"name": name,
"invite": invite,
"added_by": added_by,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
position = len(entries)
if list_kind == "aff":
guild = self.bot.get_guild(guild_id)
if guild:
await self._sync_aff_message(guild)
return position
async def remove_entry(self, guild_id: int, list_kind: str, entry_id: int) -> bool:
list_key = LIST_KEYS[list_kind]
guild_conf = self.config.guild_from_id(guild_id)
async with guild_conf.all() as conf:
entries = conf[list_key]
filtered = [e for e in entries if e["id"] != entry_id]
if len(filtered) == len(entries):
return False
conf[list_key] = filtered
if list_kind == "aff":
guild = self.bot.get_guild(guild_id)
if guild:
await self._sync_aff_message(guild)
return True
async def get_entry(self, guild_id: int, list_kind: str, entry_id: int) -> Optional[dict]:
list_key = LIST_KEYS[list_kind]
entries = await getattr(self.config.guild_from_id(guild_id), list_key)()
for entry in entries:
if entry["id"] == entry_id:
return entry
return None
async def _sync_aff_message(self, guild: discord.Guild) -> None:
"""(Re)render the persistent affiliate board — one plain-text message per
10-entry page. Creates new pages as the list grows, edits existing pages
in place, and deletes trailing pages if the list shrank."""
conf = await self.config.guild(guild).all()
channel_id = conf["aff_channel"]
if not channel_id:
return
channel = guild.get_channel(channel_id)
if not isinstance(channel, discord.TextChannel):
return
pages = render_pages(conf["affiliates"], guild.name)
old_ids = conf["aff_message_ids"]
new_ids: List[int] = []
for i, page_text in enumerate(pages):
message = None
if i < len(old_ids):
try:
message = await channel.fetch_message(old_ids[i])
except (discord.NotFound, discord.Forbidden, discord.HTTPException):
message = None
if message:
try:
await message.edit(content=page_text)
new_ids.append(message.id)
continue
except discord.HTTPException:
pass # fall through and recreate this page below
try:
new_message = await channel.send(page_text)
except discord.HTTPException:
LOG.exception("Affiliates: failed to send board page %d in guild %s", i + 1, guild.id)
continue
new_ids.append(new_message.id)
# The list shrank — clean up now-unused trailing pages.
for stale_id in old_ids[len(pages):]:
try:
stale_message = await channel.fetch_message(stale_id)
await stale_message.delete()
except (discord.NotFound, discord.Forbidden, discord.HTTPException):
pass
await self.config.guild(guild).aff_message_ids.set(new_ids)
# ══════════════════════════════════════════════════════════
# on_member_join: POJ + DM affiliates
# ══════════════════════════════════════════════════════════
async def _fire_poj(self, member: discord.Member, conf: Dict[str, Any]) -> None:
channels = conf["poj_channels"]
if not channels:
return
template = conf["poj_message"] or "{member}"
delay = conf["poj_delay"]
content = template.replace("{member}", member.mention)
async def _ping_one(channel_id: int) -> None:
channel = member.guild.get_channel(channel_id)
if not isinstance(channel, (discord.TextChannel, discord.VoiceChannel, discord.Thread)):
return
perms = channel.permissions_for(member.guild.me)
if not perms.send_messages:
return
try:
msg = await channel.send(content, allowed_mentions=discord.AllowedMentions(users=True))
except discord.HTTPException:
return
await asyncio.sleep(delay)
try:
await msg.delete()
except discord.HTTPException:
pass
# Fire-and-forget per channel so a slow/rate-limited channel never
# delays the ping (or the DM affiliate send) in any other channel.
for channel_id in channels:
asyncio.create_task(_ping_one(channel_id))
async def _send_dm_affiliates(self, member: discord.Member, conf: Dict[str, Any]) -> None:
entries = conf["dm_affiliates"]
if not entries:
return
pages = render_pages(entries, member.guild.name)
try:
for page_text in pages:
await member.send(page_text)
except (discord.Forbidden, discord.HTTPException):
pass # DMs closed or another delivery failure — not actionable
@commands.Cog.listener()
async def on_member_join(self, member: discord.Member) -> None:
if member.bot:
return
conf = await self.config.guild(member.guild).all()
if conf["poj_enabled"]:
await self._fire_poj(member, conf)
if conf["dm_enabled"]:
await self._send_dm_affiliates(member, conf)
# ══════════════════════════════════════════════════════════
# POJ commands — every subcommand here is admin-only, so the check
# lives once on the top-level group.
# ══════════════════════════════════════════════════════════
@commands.group(name="poj")
@commands.guild_only()
@commands.admin_or_permissions(administrator=True)
async def poj(self, ctx: commands.Context):
"""👋 POJ — Ping on Join: mention new members, then auto-delete the ping."""
@poj.command(name="enable", aliases=["on"])
async def poj_enable(self, ctx: commands.Context):
"""Enable POJ for this server."""
await self.config.guild(ctx.guild).poj_enabled.set(True)
await ctx.send(embed=ok_embed("✅ POJ enabled."))
@poj.command(name="disable", aliases=["off"])
async def poj_disable(self, ctx: commands.Context):
"""Disable POJ for this server."""
await self.config.guild(ctx.guild).poj_enabled.set(False)
await ctx.send(embed=info_embed("POJ disabled."))
@poj.group(name="channel")
async def poj_channel(self, ctx: commands.Context):
"""Manage which channels ping new members on join."""
@poj_channel.command(name="add")
async def poj_channel_add(
self, ctx: commands.Context,
channel: Union[discord.TextChannel, discord.VoiceChannel, discord.Thread],
):
async with self.config.guild(ctx.guild).poj_channels() as chans:
if channel.id not in chans:
chans.append(channel.id)
await ctx.send(embed=ok_embed(f"✅ {channel.mention} will now ping new members."))
@poj_channel.command(name="remove")
async def poj_channel_remove(
self, ctx: commands.Context,
channel: Union[discord.TextChannel, discord.VoiceChannel, discord.Thread],
):
async with self.config.guild(ctx.guild).poj_channels() as chans:
if channel.id in chans:
chans.remove(channel.id)
await ctx.send(embed=ok_embed(f"✅ {channel.mention} removed from POJ."))
@poj_channel.command(name="list")
async def poj_channel_list(self, ctx: commands.Context):
chans = await self.config.guild(ctx.guild).poj_channels()
if not chans:
return await ctx.send(embed=info_embed("No POJ channels configured."))
await ctx.send("POJ channels:\n" + "\n".join(f"<#{c}>" for c in chans))
@poj.command(name="delay")
async def poj_delay(self, ctx: commands.Context, seconds: int):
"""Set how long (1-60s) the ping stays before auto-deleting."""
if seconds < 1 or seconds > 60:
return await ctx.send(embed=err_embed("Delay must be between 1 and 60 seconds."))
await self.config.guild(ctx.guild).poj_delay.set(seconds)
await ctx.send(embed=ok_embed(f"✅ POJ delete-delay set to **{seconds}s**."))
@poj.command(name="message")
async def poj_message(self, ctx: commands.Context, *, template: str):
"""Set the ping message template. Must include `{member}`."""
if "{member}" not in template:
return await ctx.send(embed=err_embed("Template must include `{member}`."))
await self.config.guild(ctx.guild).poj_message.set(template)
await ctx.send(embed=ok_embed("✅ POJ message template updated."))
@poj.command(name="settings", aliases=["status"])
async def poj_settings(self, ctx: commands.Context):
conf = await self.config.guild(ctx.guild).all()
embed = discord.Embed(title="👋 POJ Settings", colour=COLOUR_INFO)
embed.add_field(name="Status", value="✅ Enabled" if conf["poj_enabled"] else "❌ Disabled", inline=True)
embed.add_field(name="Delay", value=f"{conf['poj_delay']}s", inline=True)
embed.add_field(
name="Channels",
value=", ".join(f"<#{c}>" for c in conf["poj_channels"]) or "None",
inline=False,
)
embed.add_field(name="Message Template", value=f"`{conf['poj_message']}`", inline=False)
embed.set_footer(text=f"Affiliates v{self.__version__}")
await ctx.send(embed=embed)
# ══════════════════════════════════════════════════════════
# Affiliate board commands — mixed access, so the admin check goes on
# each management subcommand individually; `list` stays open to anyone.
# ══════════════════════════════════════════════════════════
@commands.group(name="aff", aliases=["affiliate", "affiliates"])
@commands.guild_only()
async def aff(self, ctx: commands.Context):
"""🤝 Affiliates — the affiliate-server board and DM affiliates."""
@aff.command(name="channel")
@commands.admin_or_permissions(administrator=True)
async def aff_channel(self, ctx: commands.Context, channel: discord.TextChannel):
"""Set the channel the affiliate board is posted/kept updated in."""
perms = channel.permissions_for(ctx.guild.me)
if not perms.send_messages:
return await ctx.send(embed=err_embed(f"I need Send Messages in {channel.mention}."))
await self.config.guild(ctx.guild).aff_channel.set(channel.id)
await self.config.guild(ctx.guild).aff_message_ids.set([])
await self._sync_aff_message(ctx.guild)
await ctx.send(embed=ok_embed(f"✅ Affiliate board set to {channel.mention}."))
@aff.command(name="add")
@commands.admin_or_permissions(administrator=True)
async def aff_add(self, ctx: commands.Context):
"""Add a new affiliate via a short modal (Server Name + Server Invite)."""
channel_id = await self.config.guild(ctx.guild).aff_channel()
if not channel_id:
return await ctx.send(embed=err_embed("Set a board channel first: `[p]aff channel #channel`."))
view = AddAffiliateButtonView(self, ctx.guild.id, "aff", ctx.author.id)
await ctx.send(embed=info_embed("Click below to add a new affiliate."), view=view)
@aff.command(name="remove")
@commands.admin_or_permissions(administrator=True)
async def aff_remove(self, ctx: commands.Context, page: int = 1):
"""Remove an affiliate via a select menu + confirmation.
Pass a page number if there are more than 25 affiliates (each select menu can only list 25 at a time).
"""
entries = await self.config.guild(ctx.guild).affiliates()
if not entries:
return await ctx.send(embed=info_embed("There are no affiliates to remove."))
total_pages = (len(entries) + 24) // 25
page = max(1, min(page, total_pages))
view = RemoveSelectView(self, ctx.guild.id, "aff", ctx.author.id, entries, page)
desc = "Choose an affiliate to remove:"
if total_pages > 1:
desc += f"\nPage **{page}/{total_pages}** — run `[p]aff remove <page>` to see others."
await ctx.send(embed=info_embed(desc), view=view)
@aff.command(name="list")
async def aff_list(self, ctx: commands.Context):
"""Preview the current affiliate list."""
entries = await self.config.guild(ctx.guild).affiliates()
for page_text in render_pages(entries, ctx.guild.name):
await ctx.send(page_text)
@aff.command(name="refresh")
@commands.admin_or_permissions(administrator=True)
async def aff_refresh(self, ctx: commands.Context):
"""Force-recreate/resync the persistent board message (recovery command)."""
channel_id = await self.config.guild(ctx.guild).aff_channel()
if not channel_id:
return await ctx.send(embed=err_embed("No board channel configured yet."))
await self._sync_aff_message(ctx.guild)
await ctx.send(embed=ok_embed("✅ Affiliate board refreshed."))
@aff.command(name="settings", aliases=["status"])
@commands.admin_or_permissions(administrator=True)
async def aff_settings(self, ctx: commands.Context):
conf = await self.config.guild(ctx.guild).all()
channel = ctx.guild.get_channel(conf["aff_channel"]) if conf["aff_channel"] else None
embed = discord.Embed(title="🤝 Affiliates Settings", colour=COLOUR_INFO)
embed.add_field(name="Board Channel", value=channel.mention if channel else "Not set", inline=True)
embed.add_field(name="Affiliates", value=str(len(conf["affiliates"])), inline=True)
embed.add_field(
name="DM Affiliates",
value=f"{'✅ Enabled' if conf['dm_enabled'] else '❌ Disabled'} — {len(conf['dm_affiliates'])} entries",
inline=False,
)
embed.set_footer(text=f"Affiliates v{self.__version__}")
await ctx.send(embed=embed)
# ── DM affiliates ────────────────────────────────────────
@aff.group(name="dm")
async def aff_dm(self, ctx: commands.Context):
"""Manage the separate DM-affiliates list, sent to new members on join."""
@aff_dm.command(name="add")
@commands.admin_or_permissions(administrator=True)
async def aff_dm_add(self, ctx: commands.Context):
"""Add a new DM affiliate via a short modal (Server Name + Server Invite)."""
view = AddAffiliateButtonView(self, ctx.guild.id, "dm", ctx.author.id)
await ctx.send(embed=info_embed("Click below to add a new DM affiliate."), view=view)
@aff_dm.command(name="remove")
@commands.admin_or_permissions(administrator=True)
async def aff_dm_remove(self, ctx: commands.Context, page: int = 1):
"""Remove a DM affiliate via a select menu + confirmation.
Pass a page number if there are more than 25 DM affiliates (each select menu can only list 25 at a time).
"""
entries = await self.config.guild(ctx.guild).dm_affiliates()
if not entries:
return await ctx.send(embed=info_embed("There are no DM affiliates to remove."))
total_pages = (len(entries) + 24) // 25
page = max(1, min(page, total_pages))
view = RemoveSelectView(self, ctx.guild.id, "dm", ctx.author.id, entries, page)
desc = "Choose a DM affiliate to remove:"
if total_pages > 1:
desc += f"\nPage **{page}/{total_pages}** — run `[p]aff dm remove <page>` to see others."
await ctx.send(embed=info_embed(desc), view=view)
@aff_dm.command(name="list")
async def aff_dm_list(self, ctx: commands.Context):
"""Preview the current DM-affiliate list."""
entries = await self.config.guild(ctx.guild).dm_affiliates()
for page_text in render_pages(entries, ctx.guild.name):
await ctx.send(page_text)
@aff_dm.command(name="enable", aliases=["on"])
@commands.admin_or_permissions(administrator=True)
async def aff_dm_enable(self, ctx: commands.Context):
"""Start DMing new members the DM-affiliate list on join."""
await self.config.guild(ctx.guild).dm_enabled.set(True)
await ctx.send(embed=ok_embed("✅ New members will now be DMed the DM-affiliate list."))
@aff_dm.command(name="disable", aliases=["off"])
@commands.admin_or_permissions(administrator=True)
async def aff_dm_disable(self, ctx: commands.Context):
"""Stop DMing new members the DM-affiliate list."""
await self.config.guild(ctx.guild).dm_enabled.set(False)
await ctx.send(embed=info_embed("DM affiliates disabled."))

View file

@ -1,22 +0,0 @@
{
"author": ["everestmcarthur"],
"install_msg": "Thanks for installing **Affiliates**! Set up POJ with `[p]poj channel add #channel` then `[p]poj enable`. Set up the affiliate board with `[p]aff channel #channel` then `[p]aff add`. DM affiliates work the same way via `[p]aff dm add`, then `[p]aff dm enable` to start sending them to new members.",
"name": "Affiliates",
"short": "Ping-on-Join, a persistent affiliate-server board, and a separate DM-affiliates list sent to new members.",
"description": "Affiliates bundles three related join-time features. POJ (Ping on Join) mentions new human members in configured channels then auto-deletes the ping after a configurable delay, so members get notified without cluttering chat history. The affiliate board is a staff-configured channel holding a persistent, auto-updating message listing partner servers (name + invite), numbered 1..x, managed entirely through Discord UI: `aff add` opens a modal (Server Name + Server Invite), `aff remove` opens a select menu with a confirmation step, and `aff list` previews the current entries. The board message survives bot restarts and is recreated automatically if it's ever deleted. DM Affiliates mirrors that same add/remove/list management on a completely separate list that isn't posted anywhere — instead every new member is sent it as a DM the moment they join, independently toggleable from POJ and the board.",
"min_bot_version": "3.5.0",
"min_python_version": [3, 9, 0],
"tags": [
"affiliates",
"welcome",
"join",
"ping",
"partners",
"advertising",
"modal",
"dm"
],
"type": "COG",
"requirements": []
}

View file

@ -1,191 +0,0 @@
"""
Interactive components for the Affiliates cog: the "add" modal (and the
button that opens it, since modals can only be opened in response to a
component interaction, never a bare prefix command), and the
select-then-confirm flow used to remove an entry.
"""
from __future__ import annotations
from typing import List
import discord
from discord import ui
COLOUR_OK = discord.Colour.green()
COLOUR_INFO = discord.Colour.blurple()
COLOUR_ERR = discord.Colour.red()
def ok_embed(text: str) -> discord.Embed:
return discord.Embed(description=text, colour=COLOUR_OK)
def info_embed(text: str) -> discord.Embed:
return discord.Embed(description=text, colour=COLOUR_INFO)
def err_embed(text: str) -> discord.Embed:
return discord.Embed(description=text, colour=COLOUR_ERR)
class AffiliateModal(ui.Modal):
# Capped tightly (not just for tidiness): the board renders 10 entries per
# plain-text message, and Discord caps message content at 2000 characters.
# These max_lengths keep a worst-case 10-entry page under that cap with
# margin to spare — see the render_pages()/`_sync_aff_message` docstring.
server_name = ui.TextInput(
label="Server Name",
placeholder="e.g. Ruby's Hangout",
max_length=60,
style=discord.TextStyle.short,
)
server_invite = ui.TextInput(
label="Server Invite",
placeholder="e.g. https://discord.gg/abc123",
max_length=100,
style=discord.TextStyle.short,
)
def __init__(self, cog, guild_id: int, list_kind: str):
super().__init__(title="➕ Add Affiliate" if list_kind == "aff" else "➕ Add DM Affiliate")
self.cog = cog
self.guild_id = guild_id
self.list_kind = list_kind # "aff" or "dm"
async def on_submit(self, interaction: discord.Interaction) -> None:
name = str(self.server_name).strip()
invite = str(self.server_invite).strip()
if not name or not invite:
return await interaction.response.send_message(
embed=err_embed("Both fields are required."), ephemeral=True
)
position = await self.cog.add_entry(self.guild_id, self.list_kind, name, invite, interaction.user.id)
if position is None:
return await interaction.response.send_message(
embed=err_embed("That list is full — it already holds the maximum of 100 entries."),
ephemeral=True,
)
label = "Affiliate" if self.list_kind == "aff" else "DM Affiliate"
await interaction.response.send_message(
embed=ok_embed(f"✅ **{name}** added as {label} **#{position}**."), ephemeral=True
)
class AddAffiliateButtonView(ui.View):
"""Sent in response to `aff add`/`aff dm add` — clicking it is the component
interaction needed to actually open the modal."""
def __init__(self, cog, guild_id: int, list_kind: str, author_id: int):
super().__init__(timeout=180)
self.cog = cog
self.guild_id = guild_id
self.list_kind = list_kind
self.author_id = author_id
async def interaction_check(self, interaction: discord.Interaction) -> bool:
if interaction.user.id != self.author_id:
await interaction.response.send_message(
"Not your prompt — run the command yourself.", ephemeral=True
)
return False
return True
@ui.button(label="Add Affiliate", style=discord.ButtonStyle.success, emoji="➕")
async def add_btn(self, interaction: discord.Interaction, button: ui.Button) -> None:
await interaction.response.send_modal(AffiliateModal(self.cog, self.guild_id, self.list_kind))
class RemoveConfirmView(ui.View):
def __init__(self, cog, guild_id: int, list_kind: str, author_id: int, entry_id: int, entry_name: str):
super().__init__(timeout=60)
self.cog = cog
self.guild_id = guild_id
self.list_kind = list_kind
self.author_id = author_id
self.entry_id = entry_id
self.entry_name = entry_name
async def interaction_check(self, interaction: discord.Interaction) -> bool:
if interaction.user.id != self.author_id:
await interaction.response.send_message("Not your confirmation.", ephemeral=True)
return False
return True
@ui.button(label="Remove", style=discord.ButtonStyle.danger, emoji="🗑️")
async def confirm(self, interaction: discord.Interaction, button: ui.Button) -> None:
removed = await self.cog.remove_entry(self.guild_id, self.list_kind, self.entry_id)
if removed:
await interaction.response.edit_message(
embed=ok_embed(f"✅ Removed **{self.entry_name}**."), view=None
)
else:
await interaction.response.edit_message(
embed=err_embed("That entry was already removed."), view=None
)
self.stop()
@ui.button(label="Cancel", style=discord.ButtonStyle.secondary, emoji="❌")
async def cancel(self, interaction: discord.Interaction, button: ui.Button) -> None:
await interaction.response.edit_message(embed=info_embed("Cancelled — nothing was removed."), view=None)
self.stop()
class RemoveSelectView(ui.View):
"""Sent in response to `aff remove`/`aff dm remove` — populates a Select
from the current entries; picking one swaps this message to a confirm step."""
def __init__(
self, cog, guild_id: int, list_kind: str, author_id: int, entries: List[dict], page: int = 1
):
super().__init__(timeout=180)
self.cog = cog
self.guild_id = guild_id
self.list_kind = list_kind
self.author_id = author_id
# Discord select menus cap at 25 options, but the list can hold up to
# 100 — slice per page while keeping each label's true global position
# (not just its position within this page).
start = (page - 1) * 25
page_entries = entries[start:start + 25]
options = [
discord.SelectOption(label=f"#{start + i + 1} — {e['name'][:80]}", value=str(e["id"]))
for i, e in enumerate(page_entries)
]
placeholder = "Choose an entry to remove..."
total_pages = (len(entries) + 24) // 25
if total_pages > 1:
placeholder = f"Choose an entry to remove... (page {page}/{total_pages})"
self.select = ui.Select(placeholder=placeholder, options=options)
self.select.callback = self._on_select
self.add_item(self.select)
async def interaction_check(self, interaction: discord.Interaction) -> bool:
if interaction.user.id != self.author_id:
await interaction.response.send_message(
"Not your prompt — run the command yourself.", ephemeral=True
)
return False
return True
async def _on_select(self, interaction: discord.Interaction) -> None:
entry_id = int(self.select.values[0])
entry = await self.cog.get_entry(self.guild_id, self.list_kind, entry_id)
if entry is None:
await interaction.response.edit_message(
embed=err_embed("That entry no longer exists — it may have already been removed."),
view=None,
)
return
confirm_view = RemoveConfirmView(
self.cog, self.guild_id, self.list_kind, self.author_id, entry_id, entry["name"]
)
await interaction.response.edit_message(
embed=info_embed(f"Remove **{entry['name']}**? This can't be undone."),
view=confirm_view,
)

View file

@ -1,5 +0,0 @@
from .antidoxxing import AntiDoxxing
async def setup(bot):
await bot.add_cog(AntiDoxxing(bot))

View file

@ -1,560 +0,0 @@
"""
AntiDoxxing Red Cog — configurable, safe defaults
This revision adds full guild-level configuration for detectors, thresholds,
whitelist/exclusions, auto-action escalation settings, and optional global
webhook logging. Defaults are conservative: cog is enabled by default but
phone/SSN/email detectors are off to avoid false positives. Immediate
`reject` action deletes the offending message; auto-actions (escalation)
are disabled by default (action="none").
Admin commands added for runtime configuration.
"""
import re
import ipaddress
import logging
import unicodedata
import asyncio
import time
from typing import Optional, Dict, List, Tuple
import aiohttp
import discord
from redbot.core import commands, Config, checks
log = logging.getLogger("red.antidoxxing")
class AntiDoxxing(commands.Cog):
"""Detect and remove/doquarantine common doxxing attempts.
Configurable per-guild and with a single optional global webhook for
moderator logs.
"""
__version__ = "1.0.0"
def __init__(self, bot):
self.bot = bot
self.config = Config.get_conf(self, identifier=1234567890123, force_registration=True)
# Global options
self.config.register_global(webhook_url=None)
# Guild defaults — conservative and safe
self.config.register_guild(
mod_channel=None,
enabled=True,
filter_level="high", # low|medium|high|paranoid
quarantine_threshold=0.6,
reject_threshold=0.85,
detect_phone=False,
detect_ssn=False,
detect_email=False,
detect_ipv4_candidates=False,
ignore_code_blocks=True,
strip_mentions=True,
strip_urls=True,
whitelist_role_ids=[],
excluded_channel_ids=[],
blocklist_regex_path=None,
auto_action={"violations": 3, "interval_minutes": 60, "action": "none"},
log_raw_message=False,
)
# Sanitization helpers
self._markdown_strip_trans = str.maketrans("", "", "`*_~|>")
self._invisible_chars = {
"\u200B": "",
"\u200C": "",
"\u200D": "",
"\uFEFF": "",
"\u2060": "",
}
# Basic homoglyph map (keeps hex letters intact to avoid breaking IPv6)
homoglyph_pairs = {
"o": "0",
"O": "0",
"i": "1",
"I": "1",
"l": "1",
"L": "1",
"z": "2",
"Z": "2",
"e": "3",
"E": "3",
"a": "4",
"A": "4",
"s": "5",
"S": "5",
"g": "6",
"G": "6",
"t": "7",
"T": "7",
"b": "8",
"B": "8",
"q": "9",
"Q": "9",
}
self._homoglyph_trans = {ord(k): v for k, v in homoglyph_pairs.items()}
# Candidate regexes (kept conservative to avoid ReDoS)
self._ipv4_candidate_re = re.compile(r"(?:\d{1,3}(?:[.\-\s]?)){3}\d{1,3}")
self._ipv6_candidate_re = re.compile(r"(?:[0-9A-Fa-f:]{2,})")
self._gps_candidate_re = re.compile(r"([+-]?\d{1,3}\.\d+)[,\s]+([+-]?\d{1,3}\.\d+)")
self._email_re = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
# In-memory escalation tracking (best-effort)
self._violation_history: Dict[Tuple[int, int], List[float]] = {}
# Weights for simple scoring (only used if multiple detectors enabled)
self._weights = {"IPv4": 1.0, "IPv6": 1.0, "GPS": 0.9, "Phone": 0.9, "SSN": 1.0, "Email": 0.8}
# ------------------ Admin commands ------------------
@checks.admin_or_permissions(manage_guild=True)
@commands.guild_only()
@commands.command(name="setmodchannel", help="Set the moderation channel for antidoxxing logs. Use without args to unset.")
async def setmodchannel(self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None):
guild = ctx.guild
if channel is None:
await self.config.guild(guild).mod_channel.set(None)
await ctx.send("AntiDoxxing moderation channel unset.")
return
await self.config.guild(guild).mod_channel.set(channel.id)
await ctx.send(f"AntiDoxxing moderation channel set to #{channel.name} (ID: {channel.id}).")
@checks.admin_or_permissions(manage_guild=True)
@commands.guild_only()
@commands.command(name="setenabled", help="Enable or disable AntiDoxxing in this guild.")
async def setenabled(self, ctx: commands.Context, enabled: bool):
await self.config.guild(ctx.guild).enabled.set(bool(enabled))
await ctx.send(f"AntiDoxxing enabled set to {enabled} for this guild.")
@checks.admin_or_permissions(manage_guild=True)
@commands.guild_only()
@commands.command(name="setthresholds", help="Set quarantine and reject thresholds (floats 0-1).")
async def setthresholds(self, ctx: commands.Context, quarantine: float, reject: float):
if not (0.0 <= quarantine < reject <= 1.0):
return await ctx.send("Invalid thresholds — require 0.0 <= quarantine < reject <= 1.0")
async with self.config.guild(ctx.guild).all() as cfg:
cfg["quarantine_threshold"] = quarantine
cfg["reject_threshold"] = reject
await ctx.send(f"Thresholds set: quarantine={quarantine}, reject={reject}")
@checks.admin_or_permissions(manage_guild=True)
@commands.guild_only()
@commands.command(name="setdetector", help="Enable/disable a detector: phone|ssn|email|ipv4_candidates")
async def setdetector(self, ctx: commands.Context, detector: str, enabled: bool):
detector = detector.lower()
if detector not in ("phone", "ssn", "email", "ipv4_candidates"):
return await ctx.send("Unknown detector. Valid: phone, ssn, email, ipv4_candidates")
key = {
"phone": "detect_phone",
"ssn": "detect_ssn",
"email": "detect_email",
"ipv4_candidates": "detect_ipv4_candidates",
}[detector]
await self.config.guild(ctx.guild).set_raw(key).set(enabled) if False else await self.config.guild(ctx.guild).__getattribute__(key).set(enabled)
await ctx.send(f"Detector {detector} set to {enabled}.")
@checks.admin_or_permissions(manage_guild=True)
@commands.guild_only()
@commands.command(name="setautoaction", help="Configure auto-action: violations interval_minutes action (action = none|kick|ban|temp_ban_24h)")
async def setautoaction(self, ctx: commands.Context, violations: int, interval_minutes: int, action: str):
if violations < 1 or interval_minutes < 1:
return await ctx.send("violations and interval_minutes must be >= 1")
async with self.config.guild(ctx.guild).all() as cfg:
cfg["auto_action"] = {"violations": violations, "interval_minutes": interval_minutes, "action": action}
await ctx.send(f"Auto-action set: {violations} violations in {interval_minutes}m -> {action}")
@checks.admin_or_permissions(manage_guild=True)
@commands.guild_only()
@commands.command(name="addwhitelistrole", help="Add a role to the AntiDoxxing whitelist (bypasses checks).")
async def addwhitelistrole(self, ctx: commands.Context, role: discord.Role):
guild = ctx.guild
current = await self.config.guild(guild).whitelist_role_ids()
if role.id in current:
await ctx.send("Role already whitelisted.")
return
current.append(role.id)
await self.config.guild(guild).whitelist_role_ids.set(current)
await ctx.send(f"Role {role.name} added to AntiDoxxing whitelist.")
@checks.admin_or_permissions(manage_guild=True)
@commands.guild_only()
@commands.command(name="removewhitelistrole", help="Remove a role from the AntiDoxxing whitelist.")
async def removewhitelistrole(self, ctx: commands.Context, role: discord.Role):
guild = ctx.guild
current = await self.config.guild(guild).whitelist_role_ids()
if role.id not in current:
await ctx.send("Role not in whitelist.")
return
current.remove(role.id)
await self.config.guild(guild).whitelist_role_ids.set(current)
await ctx.send(f"Role {role.name} removed from AntiDoxxing whitelist.")
@checks.admin_or_permissions(manage_guild=True)
@commands.guild_only()
@commands.command(name="addexcludedchannel", help="Add a channel to the exclusion list for AntiDoxxing.")
async def addexcludedchannel(self, ctx: commands.Context, channel: discord.TextChannel):
guild = ctx.guild
current = await self.config.guild(guild).excluded_channel_ids()
if channel.id in current:
await ctx.send("Channel already excluded.")
return
current.append(channel.id)
await self.config.guild(guild).excluded_channel_ids.set(current)
await ctx.send(f"Channel #{channel.name} excluded from AntiDoxxing checks.")
@checks.admin_or_permissions(manage_guild=True)
@commands.guild_only()
@commands.command(name="removeexcludedchannel", help="Remove a channel from the exclusion list.")
async def removeexcludedchannel(self, ctx: commands.Context, channel: discord.TextChannel):
guild = ctx.guild
current = await self.config.guild(guild).excluded_channel_ids()
if channel.id not in current:
await ctx.send("Channel not in exclusion list.")
return
current.remove(channel.id)
await self.config.guild(guild).excluded_channel_ids.set(current)
await ctx.send(f"Channel #{channel.name} removed from exclusion list.")
# Bot-owner: global webhook for modlogs
@checks.is_owner()
@commands.command(name="setmodwebhook", help="Set a global webhook URL for AntiDoxxing modlogs. Use without args to unset.")
async def setmodwebhook(self, ctx: commands.Context, webhook_url: Optional[str] = None):
if webhook_url is None:
await self.config.webhook_url.set(None)
await ctx.send("AntiDoxxing global webhook unset.")
return
await self.config.webhook_url.set(webhook_url)
await ctx.send("AntiDoxxing global webhook set.")
# ------------------ Listeners ------------------
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
await self._process_message(message)
@commands.Cog.listener()
async def on_message_edit(self, before: discord.Message, after: discord.Message):
if before.content == after.content:
return
await self._process_message(after)
# ------------------ Core pipeline ------------------
async def _process_message(self, message: discord.Message):
try:
if message.author.bot:
return
if message.guild is None:
return
guild = message.guild
cfg = await self.config.guild(guild).all()
if not cfg.get("enabled", True):
return
if message.channel.id in (cfg.get("excluded_channel_ids") or []):
return
author_roles = {r.id for r in getattr(message.author, "roles", [])}
if set(cfg.get("whitelist_role_ids", [])) & author_roles:
return
raw = message.content or ""
if not raw:
return
# Basic sanitization
sanitized = raw.translate(self._markdown_strip_trans)
for ch, repl in self._invisible_chars.items():
if ch in sanitized:
sanitized = sanitized.replace(ch, repl)
sanitized = unicodedata.normalize("NFKC", sanitized)
# Optionally ignore code blocks (```...``` and inline `...`)
if cfg.get("ignore_code_blocks", True):
# Remove fenced code blocks and inline code
sanitized = re.sub(r"```[\s\S]*?```", "", sanitized)
sanitized = re.sub(r"`[^`]*`", "", sanitized)
# Optionally strip mentions
normalized = sanitized
if cfg.get("strip_mentions", True):
normalized = re.sub(r"<@!?#?\d+>?", "", normalized)
# Optionally strip URLs
if cfg.get("strip_urls", True):
normalized = re.sub(r"https?://\S+", "", normalized, flags=re.IGNORECASE)
# Homoglyph mapping for detection — keep hex letters intact to not break IPv6
normalized = normalized.translate(self._homoglyph_trans)
numeric_text = re.sub(r"[ \.\-]", "", normalized)
detections: List[Tuple[str, str]] = []
# IPv4 detection (strict)
if True:
for candidate in self._ipv4_candidate_re.findall(normalized):
parts = re.findall(r"\d+", candidate)
if len(parts) != 4:
# optionally treat candidates in paranoid mode
if cfg.get("detect_ipv4_candidates"):
detections.append(("IPv4-like", candidate))
continue
try:
octets = [int(p) for p in parts]
except ValueError:
continue
if all(0 <= o <= 255 for o in octets):
ipv4_str = ".".join(str(o) for o in octets)
try:
ipaddress.IPv4Address(ipv4_str)
detections.append(("IPv4", ipv4_str))
break
except Exception:
if cfg.get("detect_ipv4_candidates"):
detections.append(("IPv4-like", candidate))
continue
# IPv6 detection
if not any(d[0] == "IPv6" for d in detections):
for raw_candidate in self._ipv6_candidate_re.findall(normalized):
if ":" not in raw_candidate:
continue
if len(raw_candidate) > 128:
continue
try:
candidate = raw_candidate.strip()
ipaddress.IPv6Address(candidate)
detections.append(("IPv6", candidate))
break
except Exception:
continue
# GPS detection
if not any(d[0] == "GPS" for d in detections):
for lat_s, lon_s in self._gps_candidate_re.findall(normalized):
try:
lat = float(lat_s)
lon = float(lon_s)
except ValueError:
continue
if -90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0:
detections.append(("GPS", f"{lat},{lon}"))
break
# Optional detectors (phone/ssn/email) — conservative defaults off
if cfg.get("detect_phone", False) and not any(d[0] == "Phone" for d in detections):
for match in re.finditer(r"\d{10,11}", numeric_text):
digits = match.group(0)
if len(digits) == 10 or (len(digits) == 11 and digits.startswith("1")):
detections.append(("Phone", digits))
break
if cfg.get("detect_ssn", False) and not any(d[0] == "SSN" for d in detections):
for match in re.finditer(r"\d{9}", numeric_text):
digits = match.group(0)
area = digits[:3]
group = digits[3:5]
serial = digits[5:9]
try:
area_n = int(area)
except ValueError:
continue
if area == "000" or area == "666" or area_n >= 900:
continue
if group == "00" or serial == "0000":
continue
detections.append(("SSN", digits))
break
if cfg.get("detect_email", False) and not any(d[0] == "Email" for d in detections):
m = self._email_re.search(normalized)
if m:
detections.append(("Email", m.group(0)))
if not detections:
return
# Score (simple sum of weights)
score = sum(self._weights.get(dtype, 0.5) for dtype, _ in detections)
# Decide action
quarantine_t = cfg.get("quarantine_threshold", 0.6)
reject_t = cfg.get("reject_threshold", 0.85)
if score >= reject_t:
action = "reject"
elif score >= quarantine_t:
action = "quarantine"
else:
action = "ignore"
sanitized_preview = sanitized[:1900]
violation_lines = [f"- {dtype}: `{val}`" for dtype, val in detections]
if action == "reject":
deleted = False
try:
await message.delete()
deleted = True
except discord.Forbidden:
log.exception("Missing permission to delete message in %s", guild)
except discord.NotFound:
deleted = True
except Exception:
log.exception("Unexpected error deleting message")
# Escalation tracking (auto_action) — best-effort
await self._record_violation_and_maybe_autoban(guild.id, message.author.id, cfg.get("auto_action", {}), message)
# Log to mod channel and global webhook
await self._send_logs(guild, message, violation_lines, sanitized_preview, deleted, detections, cfg)
elif action == "quarantine":
await self._send_logs(guild, message, violation_lines, sanitized_preview, deleted=False, detections=detections, cfg=cfg, quarantined=True)
except Exception:
log.exception("Unhandled error in AntiDoxxing._process_message")
async def _send_logs(self, guild: discord.Guild, message: discord.Message, violation_lines: List[str], sanitized: str, deleted: bool = False, detections: Optional[List[Tuple[str, str]]] = None, cfg: Optional[dict] = None, quarantined: bool = False):
try:
cfg = cfg or await self.config.guild(guild).all()
author_str = f"{message.author} (ID: {message.author.id})"
jump_url = f"https://discord.com/channels/{guild.id}/{message.channel.id}/{message.id}"
status = "deleted" if deleted else ("quarantined" if quarantined else "detected")
# Build text log (sanitized by default)
body = (
f"AntiDoxxing: message {status} in #{message.channel.name} (ID: {message.channel.id})\n"
f"Guild: {guild.name} (ID: {guild.id})\n"
f"Author: {author_str}\n"
f"Reason(s):\n" + "\n".join(violation_lines) + "\n"
f"Original message content (sanitized copy):\n"
f"```{sanitized}```\n"
f"Jump (may be unavailable if message deleted): {jump_url}"
)
# Send to guild mod channel if configured
try:
mod_channel_id = cfg.get("mod_channel")
if mod_channel_id:
mod_channel = guild.get_channel(mod_channel_id) or self.bot.get_channel(mod_channel_id)
if mod_channel and isinstance(mod_channel, discord.TextChannel):
try:
await mod_channel.send(body)
except discord.Forbidden:
log.exception("Missing permission to send logs to mod channel %s", mod_channel)
except Exception:
log.exception("Failed to send mod log to channel")
except Exception:
log.exception("Error while attempting to log anti-dox action to channel")
# Global webhook (bot owner set)
try:
webhook_url = await self.config.webhook_url()
if webhook_url:
payload = {
"username": "AntiDoxxing",
"content": None,
"embeds": [
{
"title": f"AntiDoxxing - {status} - {guild.name}",
"description": "\n".join(violation_lines)[:2048],
"fields": [
{"name": "Guild", "value": f"{guild.name} (ID: {guild.id})", "inline": True},
{"name": "Channel", "value": f"#{message.channel.name} (ID: {message.channel.id})", "inline": True},
{"name": "Author", "value": author_str, "inline": False},
{"name": "Sanitized message", "value": sanitized[:1024], "inline": False},
{"name": "Jump URL", "value": jump_url, "inline": False},
],
"color": 15158332,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
],
}
await self._post_webhook(webhook_url, payload)
except Exception:
log.exception("Failed to send global webhook log")
except Exception:
log.exception("Error while preparing/sending logs")
async def _post_webhook(self, url: str, payload: dict):
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, timeout=10) as resp:
if resp.status >= 400:
log.warning("Webhook post returned status %s", resp.status)
except Exception:
log.exception("Exception while posting to webhook %s", url)
async def _record_violation_and_maybe_autoban(self, guild_id: int, user_id: int, auto_action_cfg: dict, message: discord.Message):
try:
key = (guild_id, user_id)
now_ts = time.time()
history = self._violation_history.get(key, [])
interval = auto_action_cfg.get("interval_minutes", 60) * 60
history = [t for t in history if now_ts - t <= interval]
history.append(now_ts)
self._violation_history[key] = history
required = auto_action_cfg.get("violations", 3)
action = auto_action_cfg.get("action", "none")
if required <= 0 or action == "none":
return
if len(history) >= required:
guild = message.guild
member = guild.get_member(user_id)
if member is None:
return
try:
if action == "kick":
await guild.kick(member)
log.info("Auto-action: kicked member %s in guild %s", user_id, guild_id)
elif action.startswith("temp_ban"):
hours = 24
m = re.search(r"temp_ban_(\d+)h", action)
if m:
hours = int(m.group(1))
await guild.ban(member, reason="Auto action by AntiDoxxing")
log.info("Auto-action: banned member %s in guild %s for %sh", user_id, guild_id, hours)
asyncio.create_task(self._schedule_unban(guild, user_id, hours * 3600))
elif action == "ban":
await guild.ban(member, reason="Auto action by AntiDoxxing")
log.info("Auto-action: banned member %s in guild %s", user_id, guild_id)
else:
log.info("Unknown auto_action %s", action)
except discord.Forbidden:
log.exception("Missing permission to perform auto-action %s in guild %s", action, guild_id)
except Exception:
log.exception("Exception while performing auto-action %s in guild %s", action, guild_id)
# reset history after action
self._violation_history[key] = []
except Exception:
log.exception("Error recording violation or performing auto action")
async def _schedule_unban(self, guild: discord.Guild, user_id: int, delay_seconds: int):
try:
await asyncio.sleep(delay_seconds)
try:
user = await self.bot.fetch_user(user_id)
await guild.unban(user)
log.info("Auto-action: unbanned member %s in guild %s after delay", user_id, guild.id)
except discord.NotFound:
log.warning("Unban: user %s not found in ban list for guild %s", user_id, guild.id)
except discord.Forbidden:
log.exception("Unban: missing permissions to unban %s in guild %s", user_id, guild.id)
except Exception:
log.exception("Unban: unexpected error for %s in guild %s", user_id, guild.id)
except asyncio.CancelledError:
return
except Exception:
log.exception("Error in _schedule_unban")

53
dashboard/README.md Normal file
View file

@ -0,0 +1,53 @@
# EveDash
A modern, self-contained web dashboard for Red-DiscordBot.
## Features
- **Discord OAuth2 Login** — Secure authentication via Discord
- **Guild Management** — View and manage all guilds the bot is in
- **Cog Control** — Load, unload, and reload cogs from the browser
- **Command Toggles** — Enable/disable commands per-guild
- **Real-Time WebSocket** — Live updates for messages, members, and events
- **SDK for Cogs** — Any cog can register its own settings pages with just a few lines of code
## Requirements
- `aiohttp`
- Red-DiscordBot >= 3.5.0
## Installation
```
[p]repo add EveCogs https://github.com/everestmcarthur/EveCogs
[p]cog install EveCogs dashboard
[p]load dashboard
```
## Setup
```
[p]evedash setup
```
Follow the interactive setup wizard to configure Discord OAuth2 credentials and server settings.
## Commands
| Command | Description |
|---------|-------------|
| `[p]evedash` | Show dashboard status and URL |
| `[p]evedash setup` | Interactive setup wizard |
| `[p]evedash port <port>` | Set the web server port |
| `[p]evedash host <host>` | Set the web server host/bind address |
| `[p]evedash secret <secret>` | Set the Discord OAuth2 client secret |
| `[p]evedash clientid <id>` | Set the Discord OAuth2 client ID |
| `[p]evedash redirect <uri>` | Set the OAuth2 redirect URI |
## Data Statement
This cog stores Discord user IDs for authentication sessions and blacklist management. No message content is stored.
## License
MIT

View file

@ -51,12 +51,7 @@ class EveDash(commands.Cog):
force_registration=True,
)
self.config.register_global(
# Localhost-only by default — loading this cog with zero config
# shouldn't immediately expose an unauthenticated-until-login web
# server on every network interface. Owners who want it reachable
# externally (e.g. no reverse proxy) can opt in with
# `[p]evedash host 0.0.0.0`.
host="127.0.0.1",
host="0.0.0.0",
port=42356,
client_id=None,
client_secret=None,

View file

@ -17,7 +17,6 @@ import time
import typing
import aiohttp
import discord
from aiohttp import web
if typing.TYPE_CHECKING:
@ -260,30 +259,13 @@ class DashboardServer:
f"&scope=identify+guilds"
f"&state={state}"
)
resp = _json({"url": url, "state": state})
# Bind this state to the browser that requested it via a short-lived
# cookie, so the callback can confirm the redirect actually belongs
# to a login flow we started — otherwise an attacker's own
# authorization code could be handed to a victim's browser and get
# bound to the victim's session (login CSRF / session fixation).
resp.set_cookie(
"oauth_state", state,
max_age=600,
httponly=True,
samesite="Lax",
)
return resp
return _json({"url": url, "state": state})
async def _auth_callback(self, request: web.Request) -> web.Response:
code = request.query.get("code")
if not code:
return _err("Missing authorization code")
state = request.query.get("state")
expected_state = request.cookies.get("oauth_state")
if not state or not expected_state or not hmac.compare_digest(state, expected_state):
return _err("Invalid or missing state parameter", 400)
client_id = await self.cog.config.client_id()
client_secret = await self.cog.config.client_secret()
redirect_uri = await self.cog.config.redirect_uri()
@ -319,17 +301,6 @@ class DashboardServer:
# Check access: must be bot owner or share a guild
is_owner = user_id in self.bot.owner_ids
has_guild = any(g.get_member(user_id) for g in self.bot.guilds)
if not is_owner and not has_guild:
# Cache miss rather than genuinely no shared guilds? This only
# runs once per login (not a hot path), so it's worth confirming
# with a live check before denying a real user access.
for g in self.bot.guilds:
try:
if await g.fetch_member(user_id):
has_guild = True
break
except (discord.NotFound, discord.HTTPException):
continue
if not is_owner and not has_guild:
return _err("You don't share any servers with this bot", 403)
@ -351,7 +322,6 @@ class DashboardServer:
httponly=True,
samesite="Lax",
)
resp.del_cookie("oauth_state")
return resp
async def _auth_me(self, request: web.Request) -> web.Response:
@ -467,7 +437,7 @@ class DashboardServer:
if not guild:
return _err("Guild not found", 404)
if not await self._check_guild_access(request, guild):
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
return _json({
@ -492,7 +462,7 @@ class DashboardServer:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not await self._check_guild_access(request, guild):
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
channels = []
@ -510,7 +480,7 @@ class DashboardServer:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not await self._check_guild_access(request, guild):
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
roles = []
@ -533,7 +503,7 @@ class DashboardServer:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not await self._check_guild_access(request, guild):
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
prefixes = await self.bot.get_valid_prefixes(guild)
@ -564,7 +534,7 @@ class DashboardServer:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not await self._check_guild_access(request, guild):
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
data = await request.json()
@ -598,7 +568,7 @@ class DashboardServer:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not await self._check_guild_access(request, guild):
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
disabled_cmds = await self.bot._config.guild(guild).disabled_commands()
@ -630,7 +600,7 @@ class DashboardServer:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not await self._check_guild_access(request, guild):
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
cmd_name = request.match_info["command"]
@ -723,7 +693,7 @@ class DashboardServer:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not await self._check_guild_access(request, guild):
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
tp_info = self.cog.third_parties.get_third_parties_info()
@ -733,7 +703,7 @@ class DashboardServer:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not await self._check_guild_access(request, guild):
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
cog_name = request.match_info["cog_name"]
@ -881,18 +851,7 @@ class DashboardServer:
async def _serve_spa(self, request: web.Request) -> web.Response:
path = request.match_info.get("path", "")
# Resolve and verify the final path stays inside WEB_DIR before ever
# touching the filesystem — rejects `../`, percent-encoded traversal
# (e.g. `..%2f`), and symlink escapes alike. This route is reachable
# with no authentication (see the auth middleware's path allowlist),
# so this check is the only thing standing between it and arbitrary
# file read on the host.
try:
file_path = (WEB_DIR / path).resolve()
file_path.relative_to(WEB_DIR.resolve())
except (ValueError, OSError):
return _err("Not found", 404)
file_path = WEB_DIR / path
# Serve actual files if they exist
if file_path.is_file():
@ -913,18 +872,11 @@ class DashboardServer:
except (KeyError, ValueError):
return None
async def _check_guild_access(self, request: web.Request, guild) -> bool:
def _check_guild_access(self, request: web.Request, guild) -> bool:
if request["is_owner"]:
return True
user_id = request["user_id"]
member = guild.get_member(user_id)
if not member:
# Cache miss (e.g. Members intent not warmed for this guild yet)
# shouldn't wrongly deny a real admin — fall back to a live fetch.
# Only done for single-guild endpoints (this method), never in
# bulk over every guild the bot is in — see _guilds_list.
try:
member = await guild.fetch_member(user_id)
except (discord.NotFound, discord.HTTPException):
return False
return False
return member.guild_permissions.manage_guild or member.guild_permissions.administrator

View file

@ -1,22 +0,0 @@
"""
GhostWipe - Auto-deletes a departing member's messages server-wide and
produces a full HTML audit log of what was removed.
"""
from redbot.core.bot import Red
from .ghostwipe import GhostWipe
__red_end_user_data_statement__ = (
"This cog stores configuration data (enabled state, log channel, exemption lists) per server. "
"When a member leaves, is kicked, or is banned, it deletes their messages across the server and "
"saves the deleted message content, attachments metadata, and timestamps to local disk as an HTML/JSON "
"audit report so staff can review what was removed. This data is not sent anywhere off the host "
"running the bot. Server staff can disable content capture (redacted reports) or set a retention "
"period to auto-delete old reports via `[p]ghostwipe report content` and `[p]ghostwipe retention`."
)
async def setup(bot: Red) -> None:
await bot.add_cog(GhostWipe(bot))

View file

@ -1,984 +0,0 @@
"""
GhostWipe - Deletes a departing member's messages server-wide and produces a
full HTML audit log of what was removed.
Fires only on `on_member_remove` (voluntary leave, kick, or ban - Discord
delivers all three through that single event). The departure type is then
classified via the audit log purely for reporting/toggle purposes.
"""
from __future__ import annotations
import asyncio
import base64
import json
import logging
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
import discord
from redbot.core import Config, commands
from redbot.core.bot import Red
from redbot.core.data_manager import cog_data_path
from redbot.core.utils.chat_formatting import humanize_number
from .report import generate_report_html
LOG = logging.getLogger("red.evecogs.ghostwipe")
REASON_TRIGGER_KEY = {
"left": "trigger_leave",
"kicked": "trigger_kick",
"banned": "trigger_ban",
}
REASON_LABEL = {"left": "Left", "kicked": "Kicked", "banned": "Banned", "manual": "Manual"}
REASON_COLOUR = {
"left": discord.Colour.light_grey(),
"kicked": discord.Colour.orange(),
"banned": discord.Colour.red(),
"manual": discord.Colour.blurple(),
}
COLOUR_OK = discord.Colour.green()
COLOUR_INFO = discord.Colour.blurple()
COLOUR_WARN = discord.Colour.orange()
COLOUR_ERR = discord.Colour.red()
MAX_HISTORY_ENTRIES = 50
DISCORD_ATTACHMENT_LIMIT = 8 * 1024 * 1024 # 8MB, safe default (no boost assumptions)
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
AVATAR_MAX_BYTES = 2 * 1024 * 1024 # avatars are always small; bail if something's wrong
AVATAR_FETCH_TIMEOUT = 5
def _humanize_size(num_bytes: int) -> str:
size = float(num_bytes)
for unit in ("B", "KB", "MB", "GB"):
if size < 1024 or unit == "GB":
return f"{size:.1f}{unit}" if unit != "B" else f"{int(size)}{unit}"
size /= 1024
return f"{size:.1f}GB"
class GhostWipe(commands.Cog):
"""Auto-purge a departing member's messages, with a full HTML audit log."""
__version__ = "1.0.0"
__author__ = ["everestmcarthur"]
def __init__(self, bot: Red):
self.bot = bot
self.config = Config.get_conf(self, identifier=0x67686F73_74776970, force_registration=True)
default_guild: Dict[str, Any] = {
"enabled": False,
"log_channel": None,
"trigger_leave": True,
"trigger_kick": True,
"trigger_ban": True,
"whitelist_users": [],
"ignore_roles": [],
"ignored_channels": [],
"include_threads": True,
"include_archived_threads": True,
"history_limit": 2000,
"delete_delay": 0,
"dry_run": False,
"rate_limit_delay": 0.5,
"audit_log_window": 10,
"attach_html_report": True,
"reveal_content": True,
"keep_reports_days": 30,
"history": [],
"stats": {"total_events": 0, "total_messages_deleted": 0},
}
self.config.register_guild(**default_guild)
# (guild_id, user_id) -> asyncio.Task, used to cancel a pending delayed
# purge if the member rejoins within the configured grace period.
self._pending: Dict[Tuple[int, int], asyncio.Task] = {}
async def cog_unload(self) -> None:
for task in list(self._pending.values()):
if not task.done():
task.cancel()
self._pending.clear()
# ══════════════════════════════════════════════════════════
# Helpers
# ══════════════════════════════════════════════════════════
def _reports_dir(self, guild_id: int) -> Path:
path = cog_data_path(self) / str(guild_id) / "reports"
path.mkdir(parents=True, exist_ok=True)
return path
async def _classify_departure(
self, guild: discord.Guild, member: discord.abc.Snowflake, window: int
) -> Tuple[str, Optional[str], Optional[str]]:
"""Return (reason, moderator_str, mod_reason) by inspecting the audit log.
reason is one of "banned", "kicked", "left".
"""
now = datetime.now(timezone.utc)
async def _recent_entry(action: discord.AuditLogAction):
try:
async for entry in guild.audit_logs(action=action, limit=10):
if entry.target and entry.target.id == member.id:
age = (now - entry.created_at).total_seconds()
if age <= window:
return entry
except discord.Forbidden:
return None
except discord.HTTPException:
return None
return None
ban_entry = await _recent_entry(discord.AuditLogAction.ban)
if ban_entry:
mod = str(ban_entry.user) if ban_entry.user else None
return "banned", mod, ban_entry.reason
kick_entry = await _recent_entry(discord.AuditLogAction.kick)
if kick_entry:
mod = str(kick_entry.user) if kick_entry.user else None
return "kicked", mod, kick_entry.reason
return "left", None, None
def _is_image(self, filename: str, content_type: Optional[str]) -> bool:
if content_type and content_type.startswith("image/"):
return True
return filename.lower().endswith(IMAGE_EXTENSIONS)
def _serialize_message(self, message: discord.Message) -> Dict[str, Any]:
attachments = []
for att in message.attachments:
attachments.append(
{
"filename": att.filename,
"url": att.url,
"size": _humanize_size(att.size),
"is_image": self._is_image(att.filename, att.content_type),
}
)
stickers = [s.name for s in getattr(message, "stickers", [])]
return {
"id": str(message.id),
"timestamp": message.created_at.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
"content": message.content or "",
"attachments": attachments,
"stickers": stickers,
}
async def _fetch_avatar_data_uri(self, url: str) -> Optional[str]:
"""Download the member's avatar and inline it as a base64 data URI.
The report is a standalone file that may be opened offline or in a
viewer that blocks remote network requests, so a plain https:// URL
isn't reliable there. Embedding the bytes directly guarantees the
avatar always renders. Best-effort: returns None on any failure and
the report falls back to a plain initial-letter avatar.
"""
if not url:
return None
try:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=AVATAR_FETCH_TIMEOUT)
) as resp:
if resp.status != 200:
return None
if resp.content_length and resp.content_length > AVATAR_MAX_BYTES:
return None
data = await resp.content.read(AVATAR_MAX_BYTES + 1)
if len(data) > AVATAR_MAX_BYTES:
return None
content_type = resp.content_type or "image/png"
except Exception:
LOG.debug("GhostWipe failed to download avatar for report, using fallback.", exc_info=True)
return None
encoded = base64.b64encode(data).decode("ascii")
return f"data:{content_type};base64,{encoded}"
async def _gather_target_channels(
self, guild: discord.Guild, conf: Dict[str, Any]
) -> List[Tuple[str, discord.abc.Messageable, str]]:
"""Return list of (kind, channel, display_name) to scan."""
ignored = set(conf["ignored_channels"])
targets: List[Tuple[str, discord.abc.Messageable, str]] = []
for ch in guild.text_channels:
if ch.id in ignored:
continue
targets.append(("text", ch, ch.name))
for ch in guild.voice_channels:
if ch.id in ignored:
continue
targets.append(("voice", ch, ch.name))
if conf["include_threads"]:
for th in guild.threads:
if th.id in ignored:
continue
targets.append(("thread", th, th.name))
if conf["include_archived_threads"]:
async def _archived_for(ch: discord.TextChannel) -> List[discord.Thread]:
try:
return [th async for th in ch.archived_threads(limit=100)]
except (discord.Forbidden, discord.HTTPException):
return []
candidates = [ch for ch in guild.text_channels if ch.id not in ignored]
results = await asyncio.gather(*(_archived_for(ch) for ch in candidates))
for threads in results:
for th in threads:
if th.id in ignored:
continue
targets.append(("thread", th, th.name))
return targets
async def _scan_channel(
self,
kind: str,
channel: discord.abc.Messageable,
member: discord.abc.Snowflake,
history_limit: int,
dry_run: bool,
) -> Dict[str, Any]:
limit = None if not history_limit else history_limit
perms = getattr(channel, "permissions_for", None)
me = channel.guild.me if hasattr(channel, "guild") else None
if perms and me:
p = channel.permissions_for(me)
needed = p.read_message_history and (dry_run or p.manage_messages)
if not needed:
return {
"id": str(channel.id),
"name": getattr(channel, "name", str(channel.id)),
"type": kind,
"skipped": True,
"skip_reason": "missing permissions (read message history / manage messages)",
"message_count": 0,
"messages": [],
}
def _check(m: discord.Message) -> bool:
return m.author.id == member.id
try:
if dry_run:
collected = []
async for m in channel.history(limit=limit):
if _check(m):
collected.append(m)
else:
collected = await channel.purge(limit=limit, check=_check)
except discord.Forbidden:
return {
"id": str(channel.id),
"name": getattr(channel, "name", str(channel.id)),
"type": kind,
"skipped": True,
"skip_reason": "forbidden while scanning/deleting",
"message_count": 0,
"messages": [],
}
except discord.HTTPException as exc:
return {
"id": str(channel.id),
"name": getattr(channel, "name", str(channel.id)),
"type": kind,
"skipped": True,
"skip_reason": f"discord API error: {exc}",
"message_count": 0,
"messages": [],
}
# channel.history()/purge() return newest-first; the report reads far
# more naturally in actual conversation order.
collected.sort(key=lambda m: m.created_at)
messages = [self._serialize_message(m) for m in collected]
return {
"id": str(channel.id),
"name": getattr(channel, "name", str(channel.id)),
"type": kind,
"skipped": False,
"skip_reason": None,
"message_count": len(messages),
"messages": messages,
}
async def _purge_member(
self,
guild: discord.Guild,
member: discord.abc.Snowflake,
member_display: str,
member_avatar_url: str,
reason: str,
conf: Dict[str, Any],
moderator: Optional[str] = None,
mod_reason: Optional[str] = None,
) -> None:
event_time = datetime.now(timezone.utc)
placeholder_message = await self._send_placeholder_log(
guild, conf, member_display, member.id, member_avatar_url, reason, conf["dry_run"]
)
# Kick off the avatar download alongside the channel scan instead of after
# it — they're independent, so there's no reason to pay for both in series.
avatar_task = asyncio.create_task(self._fetch_avatar_data_uri(member_avatar_url))
targets = await self._gather_target_channels(guild, conf)
history_limit = conf["history_limit"]
dry_run = conf["dry_run"]
rate_delay = conf["rate_limit_delay"]
semaphore = asyncio.Semaphore(3)
channels_data: List[Dict[str, Any]] = []
async def _worker(kind: str, channel: discord.abc.Messageable):
async with semaphore:
result = await self._scan_channel(kind, channel, member, history_limit, dry_run)
# Only pace ourselves when a channel actually had deletions to make —
# there's no delete-rate-limit to protect against on a channel with
# zero matches, and most channels a departing member never posted in
# would otherwise add pure dead time to every single purge.
if rate_delay and not result["skipped"] and result["message_count"] > 0:
await asyncio.sleep(rate_delay)
return result
results = await asyncio.gather(*(_worker(k, c) for k, c, _ in targets))
channels_data.extend(results)
total_deleted = sum(c["message_count"] for c in channels_data if not c["skipped"])
event_id = f"{int(time.time())}_{member.id}"
avatar_data_uri = await avatar_task
html = generate_report_html(
guild_name=guild.name,
member_name=member_display,
member_id=member.id,
avatar_data_uri=avatar_data_uri,
reason=reason,
event_time_str=event_time.strftime("%Y-%m-%d %H:%M:%S UTC"),
moderator=moderator,
mod_reason=mod_reason,
dry_run=dry_run,
reveal_content=conf["reveal_content"],
channels=channels_data,
version=self.__version__,
generated_at=event_time.strftime("%Y-%m-%d %H:%M:%S UTC"),
)
reports_dir = self._reports_dir(guild.id)
html_path = reports_dir / f"{event_id}.html"
json_path = reports_dir / f"{event_id}.json"
html_path.write_text(html, encoding="utf-8")
json_path.write_text(
json.dumps(
{
"event_id": event_id,
"guild_id": guild.id,
"member_id": member.id,
"member_display": member_display,
"reason": reason,
"moderator": moderator,
"mod_reason": mod_reason,
"dry_run": dry_run,
"timestamp": event_time.isoformat(),
"channels": channels_data,
},
default=str,
),
encoding="utf-8",
)
channels_affected = sum(1 for c in channels_data if not c["skipped"] and c["message_count"] > 0)
channels_skipped = sum(1 for c in channels_data if c["skipped"])
async with self.config.guild(guild).history() as history:
history.append(
{
"event_id": event_id,
"member_id": member.id,
"member_display": member_display,
"reason": reason,
"timestamp": event_time.isoformat(),
"messages_deleted": total_deleted,
"channels_affected": channels_affected,
"channels_skipped": channels_skipped,
"dry_run": dry_run,
}
)
if len(history) > MAX_HISTORY_ENTRIES:
del history[: len(history) - MAX_HISTORY_ENTRIES]
if not dry_run:
async with self.config.guild(guild).stats() as stats:
stats["total_events"] = stats.get("total_events", 0) + 1
stats["total_messages_deleted"] = stats.get("total_messages_deleted", 0) + total_deleted
self._prune_old_reports(guild.id, conf["keep_reports_days"])
LOG.info(
"GhostWipe purged %s in guild %s (%s): %d messages across %d channels",
member_display, guild.id, reason, total_deleted, channels_affected,
)
await self._send_log(
guild, conf, member_display, member.id, member_avatar_url, reason,
moderator, mod_reason, total_deleted, channels_affected, channels_skipped,
dry_run, html_path, channels_data, placeholder_message,
)
def _prune_old_reports(self, guild_id: int, keep_days: int) -> None:
if not keep_days:
return
cutoff = time.time() - (keep_days * 86400)
directory = self._reports_dir(guild_id)
for f in directory.glob("*"):
try:
if f.stat().st_mtime < cutoff:
f.unlink()
except OSError:
continue
async def _send_placeholder_log(
self,
guild: discord.Guild,
conf: Dict[str, Any],
member_display: str,
member_id: int,
avatar_url: str,
reason: str,
dry_run: bool,
) -> Optional[discord.Message]:
"""Post an immediate "in progress" notice, edited later with the real results.
Scanning + purging a whole server can take a few seconds; without this,
the log channel sits silent that whole time with nothing to show for it.
"""
channel_id = conf["log_channel"]
if not channel_id:
return None
log_channel = guild.get_channel(channel_id)
if not isinstance(log_channel, discord.TextChannel):
return None
title = f"👻 GhostWipe — {REASON_LABEL.get(reason, reason.title())}"
if dry_run:
title += " (Dry Run)"
embed = discord.Embed(
title=title,
description="🔎 Scanning the server and purging messages — results incoming...",
colour=REASON_COLOUR.get(reason, COLOUR_INFO),
)
embed.set_thumbnail(url=avatar_url)
embed.add_field(name="Member", value=f"{member_display} (`{member_id}`)", inline=True)
try:
return await log_channel.send(embed=embed)
except discord.HTTPException:
LOG.exception(
"Failed to send GhostWipe placeholder log to channel %s in guild %s", channel_id, guild.id
)
return None
async def _send_log(
self,
guild: discord.Guild,
conf: Dict[str, Any],
member_display: str,
member_id: int,
avatar_url: str,
reason: str,
moderator: Optional[str],
mod_reason: Optional[str],
total_deleted: int,
channels_affected: int,
channels_skipped: int,
dry_run: bool,
html_path: Path,
channels_data: List[Dict[str, Any]],
placeholder_message: Optional[discord.Message] = None,
) -> None:
channel_id = conf["log_channel"]
if not channel_id:
return
log_channel = guild.get_channel(channel_id)
if not isinstance(log_channel, discord.TextChannel):
return
title = f"👻 GhostWipe — {REASON_LABEL.get(reason, reason.title())}"
if dry_run:
title += " (Dry Run)"
embed = discord.Embed(title=title, colour=REASON_COLOUR.get(reason, COLOUR_INFO), timestamp=datetime.now(timezone.utc))
embed.set_thumbnail(url=avatar_url)
embed.add_field(name="Member", value=f"{member_display} (`{member_id}`)", inline=True)
embed.add_field(name="Messages Deleted", value=humanize_number(total_deleted), inline=True)
embed.add_field(name="Channels Affected", value=str(channels_affected), inline=True)
if channels_skipped:
embed.add_field(name="Channels Skipped", value=str(channels_skipped), inline=True)
if moderator:
field_val = moderator
if mod_reason:
field_val += f"\nReason: {mod_reason}"
embed.add_field(name="Actioned By", value=field_val, inline=True)
top = sorted((c for c in channels_data if not c["skipped"] and c["message_count"] > 0),
key=lambda c: c["message_count"], reverse=True)[:10]
if top:
breakdown = "\n".join(f"**{c['name']}** — {c['message_count']}" for c in top)
embed.add_field(name="Per-Channel Breakdown", value=breakdown, inline=False)
embed.set_footer(text=f"Ruby · GhostWipe v{self.__version__}")
file_obj = None
if conf["attach_html_report"]:
try:
if html_path.stat().st_size <= DISCORD_ATTACHMENT_LIMIT:
file_obj = discord.File(str(html_path), filename=f"ghostwipe_{member_id}.html")
else:
embed.add_field(
name="Report",
value="Report file exceeds Discord's upload limit — saved to disk only.",
inline=False,
)
except OSError:
pass
if placeholder_message is not None:
try:
if file_obj:
await placeholder_message.edit(embed=embed, attachments=[file_obj])
else:
await placeholder_message.edit(embed=embed)
return
except discord.HTTPException:
LOG.warning(
"GhostWipe failed to edit its placeholder log message in guild %s, sending fresh.",
guild.id,
)
try:
if file_obj:
await log_channel.send(embed=embed, file=file_obj)
else:
await log_channel.send(embed=embed)
except discord.HTTPException:
LOG.exception("Failed to send GhostWipe log to channel %s in guild %s", channel_id, guild.id)
# ══════════════════════════════════════════════════════════
# Listeners
# ══════════════════════════════════════════════════════════
@commands.Cog.listener()
async def on_member_remove(self, member: discord.Member) -> None:
guild = member.guild
conf = await self.config.guild(guild).all()
if not conf["enabled"]:
return
if member.id in conf["whitelist_users"]:
return
member_role_ids = {r.id for r in getattr(member, "roles", [])}
if member_role_ids & set(conf["ignore_roles"]):
return
reason, moderator, mod_reason = await self._classify_departure(
guild, member, conf["audit_log_window"]
)
trigger_key = REASON_TRIGGER_KEY.get(reason)
if trigger_key and not conf[trigger_key]:
return
member_display = str(member)
avatar_url = member.display_avatar.url if member.display_avatar else ""
delay = conf["delete_delay"]
key = (guild.id, member.id)
async def _runner():
try:
if delay:
await asyncio.sleep(delay)
await self._purge_member(
guild, member, member_display, avatar_url, reason, conf,
moderator, mod_reason,
)
except asyncio.CancelledError:
LOG.debug("GhostWipe purge cancelled (rejoin) for %s in guild %s", member.id, guild.id)
except Exception:
LOG.exception("GhostWipe purge failed for %s in guild %s", member.id, guild.id)
finally:
self._pending.pop(key, None)
task = self.bot.loop.create_task(_runner())
self._pending[key] = task
@commands.Cog.listener()
async def on_member_join(self, member: discord.Member) -> None:
key = (member.guild.id, member.id)
task = self._pending.pop(key, None)
if task and not task.done():
task.cancel()
# ══════════════════════════════════════════════════════════
# Commands
# ══════════════════════════════════════════════════════════
@commands.group(name="ghostwipe", aliases=["gw", "wipe"])
@commands.guild_only()
@commands.admin_or_permissions(administrator=True)
async def ghostwipe(self, ctx: commands.Context):
"""👻 GhostWipe — auto-delete a departing member's messages, with a full HTML audit log."""
@ghostwipe.command(name="enable", aliases=["on"])
async def gw_enable(self, ctx: commands.Context):
"""Enable GhostWipe for this server."""
await self.config.guild(ctx.guild).enabled.set(True)
embed = discord.Embed(
title="👻 GhostWipe Enabled",
description=(
"Departing members' messages will now be purged server-wide.\n\n"
"• Set a log channel: `[p]ghostwipe logchannel #channel`\n"
"• Review full config: `[p]ghostwipe settings`"
),
colour=COLOUR_OK,
)
await ctx.send(embed=embed)
@ghostwipe.command(name="disable", aliases=["off"])
async def gw_disable(self, ctx: commands.Context):
"""Disable GhostWipe for this server."""
await self.config.guild(ctx.guild).enabled.set(False)
await ctx.send(embed=discord.Embed(
title="👻 GhostWipe Disabled", colour=COLOUR_INFO,
description="GhostWipe will no longer act on member departures in this server.",
))
@ghostwipe.command(name="logchannel", aliases=["log"])
async def gw_logchannel(self, ctx: commands.Context, channel: discord.TextChannel = None):
"""Set or clear the channel GhostWipe sends its purge reports to."""
if channel:
await self.config.guild(ctx.guild).log_channel.set(channel.id)
desc = f"Reports will be sent to {channel.mention}"
else:
await self.config.guild(ctx.guild).log_channel.set(None)
desc = "Log channel cleared — reports are still saved to disk but not posted."
await ctx.send(embed=discord.Embed(title="👻 Log Channel Updated", description=desc, colour=COLOUR_OK))
@ghostwipe.command(name="trigger")
async def gw_trigger(self, ctx: commands.Context, kind: str, toggle: bool):
"""Toggle whether a departure type triggers a purge.
`kind` is one of `leave`, `kick`, `ban`.
"""
kind = kind.lower()
mapping = {"leave": "trigger_leave", "kick": "trigger_kick", "ban": "trigger_ban"}
if kind not in mapping:
return await ctx.send("❌ `kind` must be one of `leave`, `kick`, `ban`.")
await getattr(self.config.guild(ctx.guild), mapping[kind]).set(toggle)
state = "will" if toggle else "will NOT"
await ctx.send(f"✅ Members who **{kind}** {state} have their messages purged.")
@ghostwipe.group(name="whitelist")
async def gw_whitelist(self, ctx: commands.Context):
"""Manage users who are never purged, no matter how they leave."""
@gw_whitelist.command(name="add")
async def gw_whitelist_add(self, ctx: commands.Context, user: discord.User):
async with self.config.guild(ctx.guild).whitelist_users() as ids:
if user.id not in ids:
ids.append(user.id)
await ctx.send(f"✅ **{user}** is now whitelisted and will never be purged.")
@gw_whitelist.command(name="remove")
async def gw_whitelist_remove(self, ctx: commands.Context, user: discord.User):
async with self.config.guild(ctx.guild).whitelist_users() as ids:
if user.id in ids:
ids.remove(user.id)
await ctx.send(f"✅ **{user}** removed from the whitelist.")
@gw_whitelist.command(name="list")
async def gw_whitelist_list(self, ctx: commands.Context):
ids = await self.config.guild(ctx.guild).whitelist_users()
if not ids:
return await ctx.send("No whitelisted users.")
await ctx.send("Whitelisted users:\n" + "\n".join(f"<@{i}> (`{i}`)" for i in ids))
@ghostwipe.group(name="ignorerole")
async def gw_ignorerole(self, ctx: commands.Context):
"""Manage roles that exempt a member from being purged."""
@gw_ignorerole.command(name="add")
async def gw_ignorerole_add(self, ctx: commands.Context, role: discord.Role):
async with self.config.guild(ctx.guild).ignore_roles() as ids:
if role.id not in ids:
ids.append(role.id)
await ctx.send(f"✅ Members with **{role.name}** will no longer be purged on departure.")
@gw_ignorerole.command(name="remove")
async def gw_ignorerole_remove(self, ctx: commands.Context, role: discord.Role):
async with self.config.guild(ctx.guild).ignore_roles() as ids:
if role.id in ids:
ids.remove(role.id)
await ctx.send(f"✅ **{role.name}** removed from the ignore list.")
@gw_ignorerole.command(name="list")
async def gw_ignorerole_list(self, ctx: commands.Context):
ids = await self.config.guild(ctx.guild).ignore_roles()
roles = [ctx.guild.get_role(i) for i in ids]
roles = [r for r in roles if r]
if not roles:
return await ctx.send("No ignored roles.")
await ctx.send("Ignored roles:\n" + "\n".join(r.mention for r in roles))
@ghostwipe.group(name="ignorechannel")
async def gw_ignorechannel(self, ctx: commands.Context):
"""Manage channels/threads GhostWipe never scans."""
@gw_ignorechannel.command(name="add")
async def gw_ignorechannel_add(
self, ctx: commands.Context,
channel: Union[discord.TextChannel, discord.VoiceChannel, discord.Thread],
):
async with self.config.guild(ctx.guild).ignored_channels() as ids:
if channel.id not in ids:
ids.append(channel.id)
await ctx.send(f"✅ {channel.mention} will never be scanned.")
@gw_ignorechannel.command(name="remove")
async def gw_ignorechannel_remove(
self, ctx: commands.Context,
channel: Union[discord.TextChannel, discord.VoiceChannel, discord.Thread],
):
async with self.config.guild(ctx.guild).ignored_channels() as ids:
if channel.id in ids:
ids.remove(channel.id)
await ctx.send(f"✅ {channel.mention} removed from the ignore list.")
@gw_ignorechannel.command(name="list")
async def gw_ignorechannel_list(self, ctx: commands.Context):
ids = await self.config.guild(ctx.guild).ignored_channels()
if not ids:
return await ctx.send("No ignored channels.")
await ctx.send("Ignored channels:\n" + "\n".join(f"<#{i}>" for i in ids))
@ghostwipe.command(name="scanlimit")
async def gw_scanlimit(self, ctx: commands.Context, messages: str):
"""Set how many recent messages per channel to scan for the departing member.
Pass a number, or `none` for unlimited (scans full channel history — slower).
"""
if messages.lower() in ("none", "unlimited", "0"):
await self.config.guild(ctx.guild).history_limit.set(0)
return await ctx.send("✅ Scan limit set to **unlimited** (full channel history).")
try:
value = int(messages)
except ValueError:
return await ctx.send("❌ Provide a number, or `none` for unlimited.")
if value < 1:
return await ctx.send("❌ Must be a positive number, or `none` for unlimited.")
await self.config.guild(ctx.guild).history_limit.set(value)
await ctx.send(f"✅ Scan limit set to **{value}** messages per channel.")
@ghostwipe.command(name="threads")
async def gw_threads(self, ctx: commands.Context, toggle: bool):
"""Toggle whether active threads are scanned."""
await self.config.guild(ctx.guild).include_threads.set(toggle)
await ctx.send(f"✅ Thread scanning {'enabled' if toggle else 'disabled'}.")
@ghostwipe.command(name="archivedthreads")
async def gw_archivedthreads(self, ctx: commands.Context, toggle: bool):
"""Toggle whether archived threads are also scanned (slower, more thorough)."""
await self.config.guild(ctx.guild).include_archived_threads.set(toggle)
await ctx.send(f"✅ Archived thread scanning {'enabled' if toggle else 'disabled'}.")
@ghostwipe.command(name="dryrun")
async def gw_dryrun(self, ctx: commands.Context, toggle: bool):
"""Toggle dry-run mode: simulate and report, but never actually delete."""
await self.config.guild(ctx.guild).dry_run.set(toggle)
await ctx.send(f"✅ Dry-run mode {'enabled — nothing will be deleted' if toggle else 'disabled — purges are live'}.")
@ghostwipe.command(name="delay")
async def gw_delay(self, ctx: commands.Context, seconds: int):
"""Set a grace period (seconds) before purging starts.
If the member rejoins within this window, the purge is cancelled. Use `0` to purge immediately.
"""
if seconds < 0 or seconds > 3600:
return await ctx.send("❌ Delay must be between 0 and 3600 seconds.")
await self.config.guild(ctx.guild).delete_delay.set(seconds)
await ctx.send(f"✅ Purge delay set to **{seconds}s**.")
@ghostwipe.group(name="report")
async def gw_report(self, ctx: commands.Context):
"""Configure the HTML report output."""
@gw_report.command(name="content")
async def gw_report_content(self, ctx: commands.Context, toggle: bool):
"""Toggle whether message content is shown in the report (off = redacted, counts/meta only)."""
await self.config.guild(ctx.guild).reveal_content.set(toggle)
await ctx.send(f"✅ Report content {'revealed' if toggle else 'redacted'}.")
@gw_report.command(name="attach")
async def gw_report_attach(self, ctx: commands.Context, toggle: bool):
"""Toggle whether the HTML report file is attached to the log message."""
await self.config.guild(ctx.guild).attach_html_report.set(toggle)
await ctx.send(f"✅ HTML report attachment {'enabled' if toggle else 'disabled (reports still saved to disk)'}.")
@ghostwipe.command(name="retention")
async def gw_retention(self, ctx: commands.Context, days: int):
"""Auto-delete saved report files after this many days. Use `0` to keep forever."""
if days < 0:
return await ctx.send("❌ Days must be 0 or greater.")
await self.config.guild(ctx.guild).keep_reports_days.set(days)
await ctx.send(f"✅ Report retention set to {'forever' if days == 0 else f'{days} days'}.")
@ghostwipe.command(name="settings", aliases=["config", "status"])
async def gw_settings(self, ctx: commands.Context):
"""View GhostWipe's full configuration for this server."""
conf = await self.config.guild(ctx.guild).all()
log_ch = ctx.guild.get_channel(conf["log_channel"]) if conf["log_channel"] else None
roles = [ctx.guild.get_role(r) for r in conf["ignore_roles"]]
roles = [r for r in roles if r]
embed = discord.Embed(title="👻 GhostWipe Settings", colour=COLOUR_INFO, timestamp=datetime.now(timezone.utc))
embed.add_field(name="Status", value="✅ Enabled" if conf["enabled"] else "❌ Disabled", inline=True)
embed.add_field(name="Dry Run", value="✅" if conf["dry_run"] else "❌", inline=True)
embed.add_field(name="Log Channel", value=log_ch.mention if log_ch else "Not set", inline=True)
embed.add_field(
name="Triggers",
value=(
f"Leave: {'✅' if conf['trigger_leave'] else '❌'} | "
f"Kick: {'✅' if conf['trigger_kick'] else '❌'} | "
f"Ban: {'✅' if conf['trigger_ban'] else '❌'}"
),
inline=False,
)
embed.add_field(name="Purge Delay", value=f"{conf['delete_delay']}s", inline=True)
embed.add_field(
name="Scan Limit",
value="Unlimited" if not conf["history_limit"] else f"{conf['history_limit']} msgs/channel",
inline=True,
)
embed.add_field(name="Rate Limit Delay", value=f"{conf['rate_limit_delay']}s", inline=True)
embed.add_field(name="Include Threads", value="✅" if conf["include_threads"] else "❌", inline=True)
embed.add_field(name="Include Archived Threads", value="✅" if conf["include_archived_threads"] else "❌", inline=True)
embed.add_field(name="Audit Log Window", value=f"{conf['audit_log_window']}s", inline=True)
embed.add_field(name="Report Content", value="Revealed" if conf["reveal_content"] else "Redacted", inline=True)
embed.add_field(name="Attach HTML Report", value="✅" if conf["attach_html_report"] else "❌", inline=True)
embed.add_field(
name="Report Retention",
value="Forever" if not conf["keep_reports_days"] else f"{conf['keep_reports_days']} days",
inline=True,
)
embed.add_field(name="Whitelisted Users", value=str(len(conf["whitelist_users"])), inline=True)
embed.add_field(name="Ignored Roles", value=", ".join(r.mention for r in roles) or "None", inline=False)
embed.add_field(name="Ignored Channels", value=str(len(conf["ignored_channels"])), inline=True)
stats = conf["stats"]
embed.add_field(
name="Lifetime Stats",
value=f"{humanize_number(stats.get('total_events', 0))} events, "
f"{humanize_number(stats.get('total_messages_deleted', 0))} messages deleted",
inline=False,
)
embed.set_footer(text=f"Ruby · GhostWipe v{self.__version__}")
await ctx.send(embed=embed)
@ghostwipe.command(name="history")
async def gw_history(self, ctx: commands.Context, page: int = 1):
"""View recent GhostWipe purge events for this server."""
history = await self.config.guild(ctx.guild).history()
if not history:
return await ctx.send("No GhostWipe events recorded yet.")
per_page = 10
history = list(reversed(history))
total_pages = max(1, (len(history) + per_page - 1) // per_page)
page = max(1, min(page, total_pages))
chunk = history[(page - 1) * per_page: page * per_page]
embed = discord.Embed(title="👻 GhostWipe History", colour=COLOUR_INFO)
for entry in chunk:
dry_tag = " (dry run)" if entry.get("dry_run") else ""
embed.add_field(
name=f"{REASON_LABEL.get(entry['reason'], entry['reason'].title())}{dry_tag} — {entry['member_display']}",
value=(
f"`{entry['event_id']}` — {entry['messages_deleted']} messages across "
f"{entry['channels_affected']} channels\n{entry['timestamp'][:19]}"
),
inline=False,
)
embed.set_footer(text=f"Page {page}/{total_pages} • Use `[p]ghostwipe viewreport <event_id>` to re-send a report")
await ctx.send(embed=embed)
@ghostwipe.command(name="viewreport")
async def gw_viewreport(self, ctx: commands.Context, event_id: str):
"""Re-send a past purge event's HTML report from disk."""
safe_id = "".join(c for c in event_id if c.isalnum() or c == "_")
html_path = self._reports_dir(ctx.guild.id) / f"{safe_id}.html"
if not html_path.exists():
return await ctx.send("❌ No report found with that event ID. Check `[p]ghostwipe history`.")
await ctx.send(file=discord.File(str(html_path), filename=f"ghostwipe_{safe_id}.html"))
@ghostwipe.command(name="stats")
async def gw_stats(self, ctx: commands.Context):
"""View lifetime GhostWipe stats for this server."""
conf = await self.config.guild(ctx.guild).all()
stats = conf["stats"]
embed = discord.Embed(
title="👻 GhostWipe Stats",
colour=COLOUR_INFO,
description=(
f"**{humanize_number(stats.get('total_events', 0))}** purge events\n"
f"**{humanize_number(stats.get('total_messages_deleted', 0))}** messages deleted"
),
)
await ctx.send(embed=embed)
@ghostwipe.command(name="purge")
async def gw_purge(self, ctx: commands.Context, user: discord.User, confirm: bool = False):
"""Manually trigger a purge for a user (bypasses trigger toggles).
Still respects the whitelist and ignored roles (for current members). Requires
`confirm=True` to actually run: `[p]ghostwipe purge @user True`.
"""
conf = await self.config.guild(ctx.guild).all()
if user.id in conf["whitelist_users"]:
return await ctx.send("❌ That user is whitelisted and cannot be purged.")
member = ctx.guild.get_member(user.id)
if member:
member_role_ids = {r.id for r in member.roles}
if member_role_ids & set(conf["ignore_roles"]):
return await ctx.send("❌ That member has an ignored role and cannot be purged.")
if not confirm:
return await ctx.send(
f"⚠️ This will {'simulate deleting' if conf['dry_run'] else 'permanently delete'} "
f"every message from **{user}** across this server.\n"
f"Run `[p]ghostwipe purge {user.id} True` to confirm."
)
await ctx.send(f"👻 Starting manual purge for **{user}**... this may take a while.")
avatar_url = user.display_avatar.url if user.display_avatar else ""
await self._purge_member(
ctx.guild, user, str(user), avatar_url, "manual", conf,
moderator=str(ctx.author), mod_reason="Manual purge command",
)
await ctx.send(f"✅ Manual purge for **{user}** complete. Check the log channel or `[p]ghostwipe history`.")

View file

@ -1,24 +0,0 @@
{
"author": ["everestmcarthur"],
"install_msg": "Thanks for installing **GhostWipe**! Use `[p]ghostwipe enable` to start, then `[p]ghostwipe logchannel #channel` to receive purge reports. Run `[p]ghostwipe settings` to see the full config, or `[p]ghostwipe dryrun true` to test it safely first.",
"name": "GhostWipe",
"short": "Auto-deletes a departing member's messages server-wide (leave/kick/ban) with a full HTML audit-log viewer.",
"description": "GhostWipe fires the moment a member leaves your server — whether they left on their own, were kicked, or were banned — and purges every message they ever sent across all text channels, voice channel chats, and threads (including archived ones). Every purge produces a full audit trail: a per-guild config-driven summary embed plus a self-contained, visually rich HTML report with sidebar channel switching, per-channel message counts, search, and attachment previews, so staff can review exactly what was removed. Highly configurable per-server: independently toggle whether leaves/kicks/bans trigger a purge, whitelist specific users or roles, ignore specific channels, cap or unlimit the per-channel scan depth, add a grace-period delay that auto-cancels if the member rejoins, run in dry-run/simulation mode, redact message content in reports for privacy, and set automatic report retention. Includes a manual `purge` command for backfilling a departure GhostWipe missed, plus `history` and `viewreport` to revisit past events.",
"min_bot_version": "3.5.0",
"min_python_version": [3, 9, 0],
"tags": [
"moderation",
"logging",
"purge",
"cleanup",
"audit",
"html",
"leave",
"kick",
"ban",
"privacy"
],
"type": "COG",
"requirements": []
}

View file

@ -1,483 +0,0 @@
"""
GhostWipe HTML report generator.
Builds a single self-contained HTML file (no external resources, no required
JavaScript) that lets a moderator browse every message GhostWipe deleted for
a departing member. All content — stats, avatar, channel list, and every
message — is rendered server-side directly into the HTML/CSS, using a pure
CSS radio/label technique for channel switching. This way the report
displays correctly even in minimal viewers that don't execute scripts or
load remote network resources (a common failure mode for local file://
HTML viewers). A small script is layered on top purely as a progressive
enhancement for live search filtering; if it never runs, everything is
still fully visible via the CSS tab switcher.
"""
from __future__ import annotations
import html as html_lib
from typing import Any, Dict, List, Optional
STYLE = r"""
:root {
--bg: #0e0f13;
--bg-alt: #15171d;
--panel: #1a1d24;
--panel-alt: #20232b;
--border: #2a2e38;
--text: #e7e9ee;
--text-dim: #9096a3;
--text-faint: #5b6270;
--accent: #8b5cf6;
--accent-soft: rgba(139, 92, 246, 0.15);
--danger: #f04747;
--warn: #faa61a;
--ok: #3ba55d;
--ruby: #e0115f;
--radius: 10px;
--shadow: 0 8px 24px rgba(0,0,0,0.35);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f4f5f8; --bg-alt: #ffffff; --panel: #ffffff; --panel-alt: #f0f1f5;
--border: #e1e3e9; --text: #1a1d24; --text-dim: #565c6a; --text-faint: #8b909c;
--accent: #7c3aed; --accent-soft: rgba(124, 58, 237, 0.10); --shadow: 0 8px 24px rgba(0,0,0,0.08);
}
}
:root[data-theme="dark"] {
--bg: #0e0f13; --bg-alt: #15171d; --panel: #1a1d24; --panel-alt: #20232b;
--border: #2a2e38; --text: #e7e9ee; --text-dim: #9096a3; --text-faint: #5b6270;
--accent: #8b5cf6; --accent-soft: rgba(139, 92, 246, 0.15); --shadow: 0 8px 24px rgba(0,0,0,0.35);
}
:root[data-theme="light"] {
--bg: #f4f5f8; --bg-alt: #ffffff; --panel: #ffffff; --panel-alt: #f0f1f5;
--border: #e1e3e9; --text: #1a1d24; --text-dim: #565c6a; --text-faint: #8b909c;
--accent: #7c3aed; --accent-soft: rgba(124, 58, 237, 0.10); --shadow: 0 8px 24px rgba(0,0,0,0.08);
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; min-height: 100%;
background: var(--bg); color: var(--text);
font-family: "gg sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 14px;
}
#app { display: flex; flex-direction: column; min-height: 100vh; }
/* Header */
#header {
display: flex; align-items: center; gap: 16px;
padding: 14px 20px; background: var(--bg-alt);
border-bottom: 1px solid var(--border); flex-shrink: 0;
flex-wrap: wrap;
}
.avatar, .avatar-fallback {
width: 48px; height: 48px; border-radius: 50%; flex-shrink: 0;
border: 2px solid var(--border); background: var(--panel-alt);
}
.avatar-fallback {
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 18px; color: var(--accent);
}
#header .id-block { min-width: 180px; }
#header .username { font-weight: 700; font-size: 16px; }
#header .userid { color: var(--text-faint); font-size: 12px; }
.badge {
display: inline-block; padding: 3px 10px; border-radius: 999px;
font-size: 11px; font-weight: 700; letter-spacing: .03em; text-transform: uppercase;
}
.badge-left { background: rgba(120,120,130,0.18); color: #b7bcc7; }
.badge-kicked { background: rgba(250,166,26,0.16); color: var(--warn); }
.badge-banned { background: rgba(240,71,71,0.16); color: var(--danger); }
.badge-manual { background: var(--accent-soft); color: var(--accent); }
.badge-dryrun { background: var(--accent-soft); color: var(--accent); margin-left: 6px; }
#header .stats { display: flex; gap: 22px; margin-left: auto; flex-wrap: wrap; }
#header .stat { text-align: center; }
#header .stat .num { font-size: 20px; font-weight: 700; }
#header .stat .lbl { font-size: 10px; color: var(--text-faint); text-transform: uppercase; letter-spacing: .05em; }
#header .meta-row { width: 100%; font-size: 12px; color: var(--text-dim); margin-top: 2px; }
/* Body layout */
#body { flex: 1; display: flex; min-height: 0; }
#sidebar {
width: 280px; flex-shrink: 0; background: var(--bg-alt);
border-right: 1px solid var(--border);
display: flex; flex-direction: column; min-height: 0;
}
#sidebar .sidebar-title {
padding: 12px 16px 6px; font-size: 11px; text-transform: uppercase;
letter-spacing: .06em; color: var(--text-faint); font-weight: 700;
}
#channel-list { list-style: none; margin: 0; padding: 4px 8px; overflow-y: auto; flex: 1; }
.ch-item {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 9px 10px; border-radius: 8px; cursor: pointer; margin-bottom: 2px;
color: var(--text-dim); transition: background .1s ease;
}
.ch-item:hover { background: var(--panel-alt); color: var(--text); }
.ch-item .ch-name { display: flex; align-items: center; gap: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ch-item .ch-icon { opacity: .6; flex-shrink: 0; }
.ch-item .ch-count {
background: var(--panel-alt); color: var(--text-faint); font-size: 11px;
font-weight: 700; padding: 1px 7px; border-radius: 999px; flex-shrink: 0;
}
.ch-item.skipped { opacity: .6; }
.ch-item .skip-tag { font-size: 10px; color: var(--danger); }
#search-wrap { padding: 10px; border-top: 1px solid var(--border); flex-shrink: 0; }
#search-wrap input {
width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border);
background: var(--panel-alt); color: var(--text); font-size: 13px;
}
#search-wrap input:focus { outline: 2px solid var(--accent); }
.js-only-note { display: none; font-size: 10px; color: var(--text-faint); margin-top: 6px; }
/* Main pane */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.panel { display: none; flex-direction: column; flex: 1; min-height: 0; }
.panel-header {
padding: 12px 20px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 10px; flex-shrink: 0;
}
.panel-header h2 { margin: 0; font-size: 15px; font-weight: 700; }
.count-pill {
background: var(--panel-alt); border: 1px solid var(--border); border-radius: 999px;
padding: 2px 10px; font-size: 12px; color: var(--text-dim);
}
.panel-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
.msg {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 12px 14px; margin-bottom: 10px;
}
.msg.hidden-by-search { display: none; }
.msg .msg-top { display: flex; justify-content: space-between; color: var(--text-faint); font-size: 11px; margin-bottom: 6px; }
.msg .msg-content { white-space: pre-wrap; word-break: break-word; line-height: 1.45; }
.msg .msg-content.redacted { color: var(--text-faint); font-style: italic; }
.attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.attachment-chip {
display: flex; align-items: center; gap: 6px; background: var(--panel-alt);
border: 1px solid var(--border); border-radius: 8px; padding: 5px 10px; font-size: 12px;
color: var(--text-dim); text-decoration: none; max-width: 260px;
}
.attachment-chip:hover { border-color: var(--accent); color: var(--text); }
.attachment-img { max-width: 320px; max-height: 240px; border-radius: 8px; margin-top: 8px; display: block; border: 1px solid var(--border); }
.sticker-chip { background: var(--accent-soft); color: var(--accent); border-radius: 8px; padding: 4px 9px; font-size: 12px; margin-top: 6px; display: inline-block; }
.empty-state { text-align: center; color: var(--text-faint); padding: 60px 20px; }
.skip-banner {
background: rgba(250,166,26,0.10); border: 1px solid rgba(250,166,26,0.3); color: var(--warn);
border-radius: var(--radius); padding: 14px 16px; margin-bottom: 12px; font-size: 13px;
}
#footer {
padding: 8px 20px; border-top: 1px solid var(--border); color: var(--text-faint);
font-size: 11px; text-align: center; flex-shrink: 0;
}
.brand-ruby { color: var(--ruby); font-weight: 700; }
::-webkit-scrollbar { width: 10px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 6px; }
::-webkit-scrollbar-track { background: transparent; }
@media (max-width: 700px) {
html, body { font-size: 15px; }
#header { padding: 12px 14px; gap: 10px; }
.avatar, .avatar-fallback { width: 40px; height: 40px; }
#header .username { font-size: 14px; }
#header .id-block { min-width: 0; }
#header .stats { order: 3; width: 100%; margin-left: 0; justify-content: space-around; gap: 10px; }
#header .stat .num { font-size: 16px; }
#header .meta-row { order: 4; }
/* Stack sidebar above main content, and turn the channel list into a
horizontally-scrolling strip of chips instead of a tall vertical list —
the classic mobile pattern for a channel/tab picker. */
#body { flex-direction: column; }
#sidebar {
width: 100%; max-height: none;
border-right: none; border-bottom: 1px solid var(--border);
}
#sidebar .sidebar-title { padding: 10px 14px 4px; }
#channel-list {
display: flex; flex-direction: row; flex-wrap: nowrap;
overflow-x: auto; -webkit-overflow-scrolling: touch;
gap: 6px; padding: 4px 10px;
}
.ch-item { flex: 0 0 auto; margin-bottom: 0; }
.ch-item .ch-name { white-space: nowrap; }
#search-wrap { padding: 8px 10px; }
#main { width: 100%; }
.panel-header { padding: 10px 14px; }
.panel-body { padding: 12px 14px; }
.attachment-img { max-width: 100%; }
}
"""
SEARCH_SCRIPT = r"""
(function () {
var box = document.getElementById('search');
var note = document.getElementById('js-only-note');
if (!box) return;
if (note) note.style.display = 'block';
box.addEventListener('input', function () {
var term = box.value.toLowerCase();
var msgs = document.querySelectorAll('.msg[data-content]');
for (var i = 0; i < msgs.length; i++) {
var el = msgs[i];
var match = !term || el.getAttribute('data-content').indexOf(term) !== -1;
el.classList.toggle('hidden-by-search', !match);
}
});
})();
"""
def _badge_class(reason: str) -> str:
return {
"left": "badge-left",
"kicked": "badge-kicked",
"banned": "badge-banned",
"manual": "badge-manual",
}.get(reason, "badge-left")
def _badge_label(reason: str) -> str:
return {
"left": "Left",
"kicked": "Kicked",
"banned": "Banned",
"manual": "Manual",
}.get(reason, reason.title())
CHANNEL_ICON = {"text": "#", "thread": "\U0001F9F5", "voice": "\U0001F50A"}
def _esc(value: Any) -> str:
return html_lib.escape(str(value), quote=True)
def _render_message(msg: Dict[str, Any], redact: bool) -> str:
content = msg.get("content") or ""
search_key = _esc(content.lower()) if not redact else ""
parts = [f'<div class="msg" data-content="{search_key}">']
parts.append(
f'<div class="msg-top"><span>{_esc(msg.get("timestamp", ""))}</span>'
f'<span>#{_esc(msg.get("id", ""))}</span></div>'
)
if redact:
parts.append('<div class="msg-content redacted">[content redacted by server configuration]</div>')
else:
text = content if content else ("" if msg.get("attachments") else "[no text content]")
parts.append(f'<div class="msg-content">{_esc(text)}</div>')
attachments = msg.get("attachments") or []
if attachments:
parts.append('<div class="attachments">')
for att in attachments:
if att.get("is_image"):
parts.append(
f'<img class="attachment-img" src="{_esc(att.get("url", ""))}" '
f'alt="{_esc(att.get("filename", "image"))}" loading="lazy" '
f'onerror="this.style.display=\'none\'">'
)
else:
size = att.get("size", "")
parts.append(
f'<a class="attachment-chip" href="{_esc(att.get("url", ""))}" '
f'target="_blank" rel="noopener noreferrer">'
f'\U0001F4CE {_esc(att.get("filename", "file"))}'
f'{f" ({_esc(size)})" if size else ""}</a>'
)
parts.append("</div>")
for sticker in msg.get("stickers") or []:
parts.append(f'<span class="sticker-chip">✨ {_esc(sticker)}</span>')
parts.append("</div>")
return "".join(parts)
def _render_channel_panel(channel: Dict[str, Any], redact: bool) -> str:
cid = _esc(channel["id"])
icon = CHANNEL_ICON.get(channel.get("type"), "#")
name = _esc(channel.get("name", cid))
count = channel.get("message_count", 0)
body_parts = []
if channel.get("skipped"):
reason = _esc(channel.get("skip_reason") or "unknown reason")
body_parts.append(f'<div class="skip-banner">This channel was skipped: {reason}</div>')
else:
messages = channel.get("messages") or []
if not messages:
body_parts.append('<div class="empty-state">No messages recorded for this channel.</div>')
else:
for msg in messages:
body_parts.append(_render_message(msg, redact))
return (
f'<div class="panel" id="panel-{cid}">'
f'<div class="panel-header"><h2>{icon} {name}</h2>'
f'<span class="count-pill">{count} message{"s" if count != 1 else ""}</span></div>'
f'<div class="panel-body">{"".join(body_parts)}</div>'
f"</div>"
)
def _render_sidebar_item(channel: Dict[str, Any]) -> str:
cid = _esc(channel["id"])
icon = CHANNEL_ICON.get(channel.get("type"), "#")
name = _esc(channel.get("name", cid))
if channel.get("skipped"):
right = '<span class="skip-tag">skipped</span>'
cls = "ch-item skipped"
else:
count = channel.get("message_count", 0)
right = f'<span class="ch-count">{count}</span>'
cls = "ch-item"
return (
f'<label class="{cls}" for="tab-{cid}">'
f'<span class="ch-name"><span class="ch-icon">{icon}</span>{name}</span>{right}'
f"</label>"
)
def generate_report_html(
*,
guild_name: str,
member_name: str,
member_id: int,
reason: str,
event_time_str: str,
channels: List[Dict[str, Any]],
avatar_data_uri: Optional[str] = None,
moderator: str = None,
mod_reason: str = None,
dry_run: bool = False,
reveal_content: bool = True,
version: str = "1.0.0",
generated_at: str = "",
) -> str:
"""Build the standalone HTML report string.
Everything (stats, avatar, channel list, messages) is rendered directly
into the markup here in Python — there is no client-side rendering step,
so the report displays fully correctly even with JavaScript disabled or
blocked. ``channels`` is a list of dicts shaped like:
{
"id": str, "name": str, "type": "text"|"thread"|"voice",
"skipped": bool, "skip_reason": str | None,
"message_count": int,
"messages": [
{"id": str, "timestamp": str, "content": str,
"attachments": [{"filename", "url", "size", "is_image"}],
"stickers": [str]},
...
],
}
"""
total_deleted = sum(c["message_count"] for c in channels if not c.get("skipped"))
channels_affected = sum(1 for c in channels if not c.get("skipped") and c["message_count"] > 0)
channels_skipped = sum(1 for c in channels if c.get("skipped"))
# Only list channels that are actually relevant to this event — a full
# server-wide sidebar of untouched channels adds nothing but clutter.
# Affected channels (by message count, descending) come first; skipped ones last.
relevant = [c for c in channels if c.get("skipped") or c.get("message_count", 0) > 0]
relevant.sort(key=lambda c: (c.get("skipped", False), -c.get("message_count", 0)))
meta_bits = [f"Server: {html_lib.escape(guild_name)}"]
if moderator:
meta_bits.append(f"By: {html_lib.escape(moderator)}")
if mod_reason:
meta_bits.append(f"Reason: {html_lib.escape(mod_reason)}")
meta_line = " • ".join(meta_bits)
if avatar_data_uri:
avatar_html = f'<img class="avatar" src="{_esc(avatar_data_uri)}" alt="avatar">'
else:
initial = (member_name.strip()[:1] or "?").upper()
avatar_html = f'<div class="avatar-fallback">{_esc(initial)}</div>'
dryrun_badge = ' <span class="badge badge-dryrun">Dry Run</span>' if dry_run else ""
radios = []
sidebar_items = []
panels = []
for i, channel in enumerate(relevant):
cid = _esc(channel["id"])
checked = " checked" if i == 0 else ""
radios.append(f'<input type="radio" name="chantabs" id="tab-{cid}" class="tab-radio"{checked}>')
sidebar_items.append(_render_sidebar_item(channel))
panels.append(_render_channel_panel(channel, not reveal_content))
# The radio inputs are siblings of #app (both direct children of <body>) —
# #body/#main/#sidebar are all nested *inside* #app, not siblings of the
# radios themselves, so the `~` combinator has to anchor on #app.
tab_css_rules = []
for channel in relevant:
cid = _esc(channel["id"])
tab_css_rules.append(
f'#tab-{cid}:checked ~ #app #main #panel-{cid} {{ display: flex; }}\n'
f'#tab-{cid}:checked ~ #app #sidebar label[for="tab-{cid}"] {{ '
f'background: var(--accent-soft); color: var(--text); }}'
)
if relevant:
main_content = "".join(panels)
sidebar_content = "".join(sidebar_items)
else:
main_content = '<div class="empty-state">Nothing was deleted for this event.</div>'
sidebar_content = '<div class="empty-state" style="padding:20px 10px;">No channels affected.</div>'
title = f"Ruby — GhostWipe Report — {html_lib.escape(member_name)}"
doc = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
{STYLE}
{"".join(tab_css_rules)}
</style>
</head>
<body>
{"".join(radios)}
<div id="app">
<div id="header">
{avatar_html}
<div class="id-block">
<div class="username">{_esc(member_name)} <span class="badge {_badge_class(reason)}">{_badge_label(reason)}</span>{dryrun_badge}</div>
<div class="userid">ID: {member_id}</div>
</div>
<div class="stats">
<div class="stat"><div class="num">{total_deleted}</div><div class="lbl">Deleted</div></div>
<div class="stat"><div class="num">{channels_affected}</div><div class="lbl">Channels</div></div>
<div class="stat"><div class="num">{channels_skipped}</div><div class="lbl">Skipped</div></div>
</div>
<div class="meta-row">{meta_line}</div>
</div>
<div id="body">
<div id="sidebar">
<div class="sidebar-title">Channels</div>
<div id="channel-list">
{sidebar_content}
</div>
<div id="search-wrap">
<input id="search" type="text" placeholder="Search message content...">
<div id="js-only-note" class="js-only-note">Live filtering active</div>
</div>
</div>
<div id="main">
{main_content}
</div>
</div>
<div id="footer"><span class="brand-ruby">Ruby</span> &middot; GhostWipe v{html_lib.escape(version)} &middot; {html_lib.escape(generated_at or event_time_str)}</div>
</div>
<script>{SEARCH_SCRIPT}</script>
</body>
</html>
"""
return doc

View file

@ -1,24 +0,0 @@
"""
ImageVault - Auto-deletes posted images, re-hosting them in a private vault
channel first so they can be retrieved on request. Spoilered images stay
spoilered when sent back.
"""
from redbot.core.bot import Red
from .imagevault import ImageVault
__red_end_user_data_statement__ = (
"This cog stores configuration data (enabled state, vault channel, watch/ignore lists) per server. "
"When a member posts an image in a watched channel, this cog reposts that image (and the message's "
"text content) into a staff-configured private vault channel, then deletes the original message. "
"A small per-server index (author ID/name, channel and message IDs, timestamp, and a copy of the "
"message text) is kept on local disk so the image can be located and re-sent on request via "
"`[p]imagevault show`. This data is not sent anywhere off the host running the bot. Staff can remove "
"an index entry at any time with `[p]imagevault forget`."
)
async def setup(bot: Red) -> None:
await bot.add_cog(ImageVault(bot))

View file

@ -1,541 +0,0 @@
"""
ImageVault - Auto-deletes posted images, silently re-hosting them in a
private vault channel first, so they can be retrieved on request. Spoilered
images stay spoilered when they're sent back.
"""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional, Union
import discord
from redbot.core import Config, commands
from redbot.core.bot import Red
from redbot.core.data_manager import cog_data_path
LOG = logging.getLogger("red.evecogs.imagevault")
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".apng")
COLOUR_OK = discord.Colour.green()
COLOUR_INFO = discord.Colour.blurple()
RETRIEVE_MODES = ("anyone", "author", "staff")
def _is_image(attachment: discord.Attachment) -> bool:
if attachment.content_type and attachment.content_type.startswith("image/"):
return True
return attachment.filename.lower().endswith(IMAGE_EXTENSIONS)
def _is_staff(member: discord.Member) -> bool:
perms = member.guild_permissions
return perms.administrator or perms.manage_messages or member.id == member.guild.owner_id
class ImageVault(commands.Cog):
"""Auto-deletes images and re-hosts them in a private vault for on-request retrieval."""
__version__ = "1.0.0"
__author__ = ["everestmcarthur"]
def __init__(self, bot: Red):
self.bot = bot
self.config = Config.get_conf(self, identifier=0x696D6167_65766175_6C74, force_registration=True)
default_guild: Dict[str, Any] = {
"enabled": False,
"vault_channel": None,
"watch_channels": [], # empty = watch every channel
"ignored_channels": [],
"ignore_bots": True,
"delete_original": True,
"repost_text": True,
"max_size_mb": 25,
"retrieve_permission": "anyone", # anyone | author | staff
"stats": {"total_stored": 0},
}
self.config.register_guild(**default_guild)
self._locks: Dict[int, asyncio.Lock] = {}
# ══════════════════════════════════════════════════════════
# Index storage (per-guild JSON file: {"next_id": int, "entries": {id: {...}}})
# ══════════════════════════════════════════════════════════
def _index_path(self, guild_id: int) -> Path:
path = cog_data_path(self) / str(guild_id)
path.mkdir(parents=True, exist_ok=True)
return path / "index.json"
def _lock(self, guild_id: int) -> asyncio.Lock:
if guild_id not in self._locks:
self._locks[guild_id] = asyncio.Lock()
return self._locks[guild_id]
def _load_index(self, guild_id: int) -> Dict[str, Any]:
path = self._index_path(guild_id)
if not path.exists():
return {"next_id": 1, "entries": {}}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
LOG.exception("Failed to read ImageVault index for guild %s, resetting.", guild_id)
return {"next_id": 1, "entries": {}}
def _save_index(self, guild_id: int, data: Dict[str, Any]) -> None:
self._index_path(guild_id).write_text(json.dumps(data), encoding="utf-8")
async def _store_entry(self, guild_id: int, record: Dict[str, Any]) -> int:
async with self._lock(guild_id):
data = self._load_index(guild_id)
entry_id = data["next_id"]
data["next_id"] = entry_id + 1
data["entries"][str(entry_id)] = record
self._save_index(guild_id, data)
return entry_id
async def _get_entry(self, guild_id: int, entry_id: int) -> Optional[Dict[str, Any]]:
async with self._lock(guild_id):
data = self._load_index(guild_id)
return data["entries"].get(str(entry_id))
async def _delete_entry(self, guild_id: int, entry_id: int) -> bool:
async with self._lock(guild_id):
data = self._load_index(guild_id)
if str(entry_id) in data["entries"]:
del data["entries"][str(entry_id)]
self._save_index(guild_id, data)
return True
return False
async def _list_entries(self, guild_id: int):
async with self._lock(guild_id):
data = self._load_index(guild_id)
return data["entries"]
# ══════════════════════════════════════════════════════════
# Core: vault + delete
# ══════════════════════════════════════════════════════════
async def _handle_image_message(self, message: discord.Message, conf: Dict[str, Any]) -> None:
guild = message.guild
vault_channel_id = conf["vault_channel"]
vault_channel = guild.get_channel(vault_channel_id) if vault_channel_id else None
if not isinstance(vault_channel, discord.TextChannel):
LOG.warning("ImageVault enabled in guild %s but vault channel is missing/invalid.", guild.id)
return
perms = vault_channel.permissions_for(guild.me)
if not (perms.send_messages and perms.attach_files and perms.embed_links):
LOG.warning("ImageVault missing permissions in vault channel for guild %s.", guild.id)
return
max_bytes = conf["max_size_mb"] * 1024 * 1024
images = [a for a in message.attachments if _is_image(a)]
if not images:
return
if any(a.size > max_bytes for a in images):
# Can't selectively strip one attachment from someone else's message,
# so if any qualifying image is oversized, leave the whole message alone.
return
try:
files = [await a.to_file(spoiler=a.is_spoiler()) for a in images]
except (discord.HTTPException, discord.NotFound):
LOG.exception("ImageVault failed to fetch attachment bytes in guild %s.", guild.id)
return
embed = discord.Embed(
description=(message.content or "")[:1000] or None,
colour=COLOUR_INFO,
timestamp=message.created_at,
)
embed.set_author(
name=f"{message.author} ({message.author.id})",
icon_url=message.author.display_avatar.url if message.author.display_avatar else None,
)
embed.add_field(name="Channel", value=message.channel.mention, inline=True)
embed.add_field(name="Images", value=str(len(images)), inline=True)
embed.set_footer(text="ImageVault archive")
try:
vault_message = await vault_channel.send(embed=embed, files=files)
except discord.HTTPException:
LOG.exception("ImageVault failed to post to vault channel in guild %s.", guild.id)
return
entry_id = await self._store_entry(
guild.id,
{
"author_id": message.author.id,
"author_name": str(message.author),
"origin_channel_id": message.channel.id,
"origin_message_id": message.id,
"vault_message_id": vault_message.id,
"timestamp": message.created_at.astimezone(timezone.utc).isoformat(),
"content": (message.content or "")[:500],
"attachment_count": len(images),
},
)
async with self.config.guild(guild).stats() as stats:
stats["total_stored"] = stats.get("total_stored", 0) + 1
if conf["delete_original"]:
try:
await message.delete()
except discord.HTTPException:
LOG.exception("ImageVault failed to delete original message in guild %s.", guild.id)
return
if conf["repost_text"]:
note = discord.Embed(
description=message.content or None,
colour=COLOUR_INFO,
)
note.set_author(
name=str(message.author),
icon_url=message.author.display_avatar.url if message.author.display_avatar else None,
)
note.set_footer(
text=f"🖼️ {len(images)} image(s) archived — retrieve with "
f"[p]imagevault show {entry_id}"
)
try:
await message.channel.send(embed=note)
except discord.HTTPException:
pass
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
if not message.guild or not message.attachments:
return
guild = message.guild
conf = await self.config.guild(guild).all()
if not conf["enabled"]:
return
if conf["ignore_bots"] and message.author.bot:
return
if message.channel.id == conf["vault_channel"]:
return
watch = set(conf["watch_channels"])
ignore = set(conf["ignored_channels"])
channel_id = message.channel.id
parent_id = getattr(message.channel, "parent_id", None)
if watch:
if channel_id not in watch and parent_id not in watch:
return
elif (channel_id in ignore) or (parent_id is not None and parent_id in ignore):
return
await self._handle_image_message(message, conf)
# ══════════════════════════════════════════════════════════
# Commands
# ══════════════════════════════════════════════════════════
@commands.group(name="imagevault", aliases=["iv", "vault"])
@commands.guild_only()
async def imagevault(self, ctx: commands.Context):
"""🖼️ ImageVault — auto-delete images, retrievable on request.
Configuration and `list` are staff-only; `show`/`get`, `mine`, and `stats`
are open to whoever the `retrieve` permission mode allows.
"""
@imagevault.command(name="enable", aliases=["on"])
@commands.admin_or_permissions(administrator=True)
async def iv_enable(self, ctx: commands.Context):
"""Enable ImageVault for this server (requires a vault channel to be set first)."""
vault_id = await self.config.guild(ctx.guild).vault_channel()
if not vault_id or not ctx.guild.get_channel(vault_id):
return await ctx.send(
"❌ Set a vault channel first: `[p]imagevault vaultchannel #channel`.\n"
"Use a private channel that regular members can't see."
)
await self.config.guild(ctx.guild).enabled.set(True)
await ctx.send(embed=discord.Embed(
title="🖼️ ImageVault Enabled",
description="Images posted in watched channels will now be archived and removed.",
colour=COLOUR_OK,
))
@imagevault.command(name="disable", aliases=["off"])
@commands.admin_or_permissions(administrator=True)
async def iv_disable(self, ctx: commands.Context):
"""Disable ImageVault for this server."""
await self.config.guild(ctx.guild).enabled.set(False)
await ctx.send(embed=discord.Embed(
title="🖼️ ImageVault Disabled", colour=COLOUR_INFO,
description="ImageVault will no longer act on new images.",
))
@imagevault.command(name="vaultchannel")
@commands.admin_or_permissions(administrator=True)
async def iv_vaultchannel(self, ctx: commands.Context, channel: discord.TextChannel):
"""Set the private channel images get archived into.
This should be a channel only staff can see — every removed image is
reposted here in full (with attribution) before the original is deleted.
"""
perms = channel.permissions_for(ctx.guild.me)
if not (perms.send_messages and perms.attach_files and perms.embed_links):
return await ctx.send(f"❌ I need Send Messages, Attach Files, and Embed Links in {channel.mention}.")
await self.config.guild(ctx.guild).vault_channel.set(channel.id)
await ctx.send(embed=discord.Embed(
title="🖼️ Vault Channel Set",
description=f"Archived images will be posted to {channel.mention}.\n"
f"⚠️ Make sure regular members can't view that channel.",
colour=COLOUR_OK,
))
@imagevault.group(name="watchchannel")
@commands.admin_or_permissions(administrator=True)
async def iv_watchchannel(self, ctx: commands.Context):
"""Manage the channel watch-list. Empty list = watch every channel."""
@iv_watchchannel.command(name="add")
async def iv_watchchannel_add(
self, ctx: commands.Context,
channel: Union[discord.TextChannel, discord.VoiceChannel, discord.Thread],
):
async with self.config.guild(ctx.guild).watch_channels() as ids:
if channel.id not in ids:
ids.append(channel.id)
await ctx.send(f"✅ Now watching {channel.mention}.")
@iv_watchchannel.command(name="remove")
async def iv_watchchannel_remove(
self, ctx: commands.Context,
channel: Union[discord.TextChannel, discord.VoiceChannel, discord.Thread],
):
async with self.config.guild(ctx.guild).watch_channels() as ids:
if channel.id in ids:
ids.remove(channel.id)
await ctx.send(f"✅ No longer watching {channel.mention}.")
@iv_watchchannel.command(name="list")
async def iv_watchchannel_list(self, ctx: commands.Context):
ids = await self.config.guild(ctx.guild).watch_channels()
if not ids:
return await ctx.send("Watch list is empty — every channel is watched.")
await ctx.send("Watched channels:\n" + "\n".join(f"<#{i}>" for i in ids))
@imagevault.group(name="ignorechannel")
@commands.admin_or_permissions(administrator=True)
async def iv_ignorechannel(self, ctx: commands.Context):
"""Manage channels ImageVault never touches (only applies when the watch-list is empty)."""
@iv_ignorechannel.command(name="add")
async def iv_ignorechannel_add(
self, ctx: commands.Context,
channel: Union[discord.TextChannel, discord.VoiceChannel, discord.Thread],
):
async with self.config.guild(ctx.guild).ignored_channels() as ids:
if channel.id not in ids:
ids.append(channel.id)
await ctx.send(f"✅ {channel.mention} will be ignored.")
@iv_ignorechannel.command(name="remove")
async def iv_ignorechannel_remove(
self, ctx: commands.Context,
channel: Union[discord.TextChannel, discord.VoiceChannel, discord.Thread],
):
async with self.config.guild(ctx.guild).ignored_channels() as ids:
if channel.id in ids:
ids.remove(channel.id)
await ctx.send(f"✅ {channel.mention} removed from the ignore list.")
@iv_ignorechannel.command(name="list")
async def iv_ignorechannel_list(self, ctx: commands.Context):
ids = await self.config.guild(ctx.guild).ignored_channels()
if not ids:
return await ctx.send("No ignored channels.")
await ctx.send("Ignored channels:\n" + "\n".join(f"<#{i}>" for i in ids))
@imagevault.command(name="deleteoriginal")
@commands.admin_or_permissions(administrator=True)
async def iv_deleteoriginal(self, ctx: commands.Context, toggle: bool):
"""Toggle whether the original message is actually deleted after archiving."""
await self.config.guild(ctx.guild).delete_original.set(toggle)
await ctx.send(f"✅ Deleting originals is now {'enabled' if toggle else 'disabled (archive-only mode)'}.")
@imagevault.command(name="repost")
@commands.admin_or_permissions(administrator=True)
async def iv_repost(self, ctx: commands.Context, toggle: bool):
"""Toggle reposting the original text content (sans image) after deletion."""
await self.config.guild(ctx.guild).repost_text.set(toggle)
await ctx.send(f"✅ Text repost {'enabled' if toggle else 'disabled'}.")
@imagevault.command(name="ignorebots")
@commands.admin_or_permissions(administrator=True)
async def iv_ignorebots(self, ctx: commands.Context, toggle: bool):
"""Toggle whether images posted by other bots/webhooks are ignored."""
await self.config.guild(ctx.guild).ignore_bots.set(toggle)
await ctx.send(f"✅ Bot messages are now {'ignored' if toggle else 'processed'}.")
@imagevault.command(name="maxsize")
@commands.admin_or_permissions(administrator=True)
async def iv_maxsize(self, ctx: commands.Context, megabytes: int):
"""Set the max image size (MB) ImageVault will archive. Larger images are left alone."""
if megabytes < 1 or megabytes > 100:
return await ctx.send("❌ Must be between 1 and 100 MB.")
await self.config.guild(ctx.guild).max_size_mb.set(megabytes)
await ctx.send(f"✅ Max archived image size set to **{megabytes}MB**.")
@imagevault.command(name="retrieve")
@commands.admin_or_permissions(administrator=True)
async def iv_retrieve(self, ctx: commands.Context, mode: str):
"""Set who can retrieve archived images: `anyone`, `author`, or `staff`."""
mode = mode.lower()
if mode not in RETRIEVE_MODES:
return await ctx.send(f"❌ Must be one of: {', '.join(RETRIEVE_MODES)}.")
await self.config.guild(ctx.guild).retrieve_permission.set(mode)
await ctx.send(f"✅ Retrieval permission set to **{mode}**.")
@imagevault.command(name="settings", aliases=["status", "config"])
@commands.admin_or_permissions(administrator=True)
async def iv_settings(self, ctx: commands.Context):
"""View ImageVault's full configuration for this server."""
conf = await self.config.guild(ctx.guild).all()
vault_ch = ctx.guild.get_channel(conf["vault_channel"]) if conf["vault_channel"] else None
embed = discord.Embed(title="🖼️ ImageVault Settings", colour=COLOUR_INFO, timestamp=datetime.now(timezone.utc))
embed.add_field(name="Status", value="✅ Enabled" if conf["enabled"] else "❌ Disabled", inline=True)
embed.add_field(name="Vault Channel", value=vault_ch.mention if vault_ch else "⚠️ Not set", inline=True)
embed.add_field(name="Delete Originals", value="✅" if conf["delete_original"] else "❌ (archive-only)", inline=True)
embed.add_field(name="Repost Text", value="✅" if conf["repost_text"] else "❌", inline=True)
embed.add_field(name="Ignore Bots", value="✅" if conf["ignore_bots"] else "❌", inline=True)
embed.add_field(name="Max Size", value=f"{conf['max_size_mb']}MB", inline=True)
embed.add_field(name="Retrieval", value=conf["retrieve_permission"].title(), inline=True)
watch = conf["watch_channels"]
embed.add_field(
name="Watched Channels",
value=(", ".join(f"<#{i}>" for i in watch) if watch else "All channels"),
inline=False,
)
if conf["ignored_channels"]:
embed.add_field(name="Ignored Channels", value=", ".join(f"<#{i}>" for i in conf["ignored_channels"]), inline=False)
embed.add_field(name="Total Archived", value=str(conf["stats"].get("total_stored", 0)), inline=True)
embed.set_footer(text=f"ImageVault v{self.__version__}")
await ctx.send(embed=embed)
@imagevault.command(name="show", aliases=["get"])
async def iv_show(self, ctx: commands.Context, entry_id: int):
"""Retrieve an archived image by its ID (see the archive note or `[p]imagevault list`)."""
conf = await self.config.guild(ctx.guild).all()
record = await self._get_entry(ctx.guild.id, entry_id)
if not record:
return await ctx.send("❌ No archived image with that ID.")
mode = conf["retrieve_permission"]
if mode == "author" and ctx.author.id != record["author_id"] and not _is_staff(ctx.author):
return await ctx.send("❌ Only the original poster (or staff) can retrieve this image.")
if mode == "staff" and not _is_staff(ctx.author):
return await ctx.send("❌ Only staff can retrieve archived images here.")
vault_channel = ctx.guild.get_channel(conf["vault_channel"])
if not isinstance(vault_channel, discord.TextChannel):
return await ctx.send("❌ The vault channel is missing — cannot retrieve.")
try:
vault_message = await vault_channel.fetch_message(record["vault_message_id"])
except discord.NotFound:
return await ctx.send("❌ That image's vault entry no longer exists.")
except discord.HTTPException:
return await ctx.send("❌ Failed to fetch that image from the vault.")
try:
files = [await a.to_file(spoiler=a.is_spoiler()) for a in vault_message.attachments]
except (discord.HTTPException, discord.NotFound):
return await ctx.send("❌ Failed to re-download that image.")
embed = discord.Embed(
description=record.get("content") or None,
colour=COLOUR_INFO,
)
embed.set_author(name=record["author_name"])
embed.set_footer(text=f"Originally posted {record['timestamp'][:19]} UTC — archive #{entry_id}")
await ctx.send(embed=embed, files=files)
@imagevault.command(name="list")
@commands.admin_or_permissions(administrator=True)
async def iv_list(self, ctx: commands.Context, page: int = 1):
"""List recently archived images for this server (staff only — shows all members' entries)."""
entries = await self._list_entries(ctx.guild.id)
if not entries:
return await ctx.send("No images archived yet.")
per_page = 10
sorted_ids = sorted((int(k) for k in entries), reverse=True)
total_pages = max(1, (len(sorted_ids) + per_page - 1) // per_page)
page = max(1, min(page, total_pages))
chunk = sorted_ids[(page - 1) * per_page: page * per_page]
embed = discord.Embed(title="🖼️ ImageVault Archive", colour=COLOUR_INFO)
for eid in chunk:
e = entries[str(eid)]
embed.add_field(
name=f"#{eid} — {e['author_name']}",
value=f"<#{e['origin_channel_id']}> • {e['attachment_count']} image(s) • {e['timestamp'][:19]} UTC",
inline=False,
)
embed.set_footer(text=f"Page {page}/{total_pages} • [p]imagevault show <id>")
await ctx.send(embed=embed)
@imagevault.command(name="mine")
async def iv_mine(self, ctx: commands.Context, page: int = 1):
"""List your own archived images."""
entries = await self._list_entries(ctx.guild.id)
mine = {k: v for k, v in entries.items() if v["author_id"] == ctx.author.id}
if not mine:
return await ctx.send("You don't have any archived images.")
per_page = 10
sorted_ids = sorted((int(k) for k in mine), reverse=True)
total_pages = max(1, (len(sorted_ids) + per_page - 1) // per_page)
page = max(1, min(page, total_pages))
chunk = sorted_ids[(page - 1) * per_page: page * per_page]
embed = discord.Embed(title=f"🖼️ Your Archived Images", colour=COLOUR_INFO)
for eid in chunk:
e = mine[str(eid)]
embed.add_field(
name=f"#{eid}",
value=f"<#{e['origin_channel_id']}> • {e['attachment_count']} image(s) • {e['timestamp'][:19]} UTC",
inline=False,
)
embed.set_footer(text=f"Page {page}/{total_pages} • [p]imagevault show <id>")
await ctx.send(embed=embed)
@imagevault.command(name="forget")
@commands.admin_or_permissions(administrator=True)
async def iv_forget(self, ctx: commands.Context, entry_id: int):
"""Remove an entry from the archive index (staff only). The vault message itself is untouched."""
removed = await self._delete_entry(ctx.guild.id, entry_id)
if removed:
await ctx.send(f"✅ Archive entry #{entry_id} forgotten (the vault post itself is untouched).")
else:
await ctx.send("❌ No archived image with that ID.")
@imagevault.command(name="stats")
async def iv_stats(self, ctx: commands.Context):
"""View lifetime ImageVault stats for this server."""
conf = await self.config.guild(ctx.guild).all()
await ctx.send(embed=discord.Embed(
title="🖼️ ImageVault Stats",
description=f"**{conf['stats'].get('total_stored', 0)}** images archived",
colour=COLOUR_INFO,
))

View file

@ -1,22 +0,0 @@
{
"author": ["everestmcarthur"],
"install_msg": "Thanks for installing **ImageVault**! First create a private channel only staff can see, then run `[p]imagevault vaultchannel #that-channel` followed by `[p]imagevault enable`. Members can retrieve a removed image with `[p]imagevault show <id>` — check `[p]imagevault settings` for the full config.",
"name": "ImageVault",
"short": "Auto-deletes posted images and re-hosts them in a private vault channel so they can be retrieved on request. Spoilers stay spoilered.",
"description": "ImageVault watches for image attachments in configured channels, silently reposts each one (with full attribution and original message text) into a staff-only vault channel, then deletes the original message — keeping channels clean without actually losing anything. Anyone (or, if configured, only the original poster or staff) can bring an image back with `[p]imagevault show <id>`, and if the original was posted as a Discord spoiler it comes back spoilered too. Highly configurable per-server: choose which channels are watched (or watch everything), ignore specific channels, toggle whether originals are actually deleted (archive-only mode) or whether the text content is reposted after removal, set a max file size, ignore bot/webhook posts, and control who is allowed to retrieve archived images. Includes `list`/`mine` to browse the archive and `forget` to drop an index entry.",
"min_bot_version": "3.5.0",
"min_python_version": [3, 9, 0],
"tags": [
"images",
"moderation",
"cleanup",
"spoiler",
"archive",
"vault",
"declutter",
"media"
],
"type": "COG",
"requirements": []
}

View file

@ -3,6 +3,6 @@
"install_msg": "Welcome to **EveCogs**! Use `[p]cog list` to see available cogs.",
"name": "EveCogs",
"short": "A collection of advanced cogs for Red-DiscordBot.",
"description": "EveCogs is a premium cog repository featuring NexusCore (the ultimate all-in-one server management cog with tickets, applications, suggestions, reaction roles, giveaways, logging, moderation & economy), Wormhole v3.4.0 (cross-server relay with 85+ hybrid commands, context menus, ToS gate, reports, analytics & more), Support (modernized contact/dm system replacing Red's built-in commands with categories, logging, blocking & customization), NewHelpMenu (Components V2 help overhaul), VoiceMeister (ultimate temporary voice channel system with Join-to-Create, persistent control panel, name templates, game detection & logging), SelfbotGuard (advanced selfbot detection), ServerWhitelist (owner-only server join whitelist with blacklist, lock mode, interactive browser, purge & export), UserSlash (user-installable slash-command bridge — every Red command works in servers, DMs & group DMs), GhostWipe (auto-deletes a departing member's messages server-wide on leave/kick/ban, with a full HTML audit-log viewer), ImageVault (auto-deletes posted images, re-hosting them in a private vault channel first so they can be retrieved on request, with spoiler tags preserved), and Affiliates (Ping-on-Join, a persistent modal/select-managed affiliate-server board, and a separate DM-affiliates list sent to new members on join).",
"description": "EveCogs is a premium cog repository featuring NexusCore (the ultimate all-in-one server management cog with tickets, applications, suggestions, reaction roles, giveaways, logging, moderation & economy), Wormhole v3.4.0 (cross-server relay with 85+ hybrid commands, context menus, ToS gate, reports, analytics & more), NewHelpMenu (Components V2 help overhaul), VoiceMeister (ultimate temporary voice channel system with Join-to-Create, persistent control panel, name templates, game detection & logging), SelfbotGuard (advanced selfbot detection), ServerWhitelist (owner-only server join whitelist with blacklist, lock mode, interactive browser, purge & export), and UserSlash (user-installable slash-command bridge — every Red command works in servers, DMs & group DMs).",
"type": "REPO"
}

59
newhelpmenu/README.md Normal file
View file

@ -0,0 +1,59 @@
# NewHelpMenu
Components V2 help menu + global embed/menu override for Red-DiscordBot.
## Features
- **Components V2 Help** — Replaces Red's entire help system with Discord's Components V2 (containers, sections, text displays, thumbnails, media galleries, separators)
- **Global Embed Override** — Optionally converts ALL bot embeds and menus into Components V2 layouts
- **Categories** — Group cogs into custom categories for organized help pages
- **Accent Colours** — Customizable colour scheme per-server
- **Layout Modes** — Multiple layout styles for help pages
- **Paginated Navigation** — Interactive buttons for browsing commands
- **Select Menus** — Quick-jump to any cog or command
## Requirements
- Red-DiscordBot >= 3.5.21
- Python >= 3.9
## Installation
```
[p]repo add EveCogs https://github.com/everestmcarthur/EveCogs
[p]cog install EveCogs newhelpmenu
[p]load newhelpmenu
```
> ⚠️ This cog replaces Red's built-in `help` command. The original is restored on unload.
## Quick Start
```
[p]cv2 toggle # Enable Components V2 (off by default)
[p]cv2 settings # View all current settings
[p]cv2 color <hex> # Set accent colour
[p]cv2 category add <name> <cog1> <cog2>... # Group cogs into categories
```
## Commands
| Command | Description |
|---------|-------------|
| `[p]cv2 toggle` | Enable/disable Components V2 mode |
| `[p]cv2 settings` | View current configuration |
| `[p]cv2 color <hex>` | Set accent colour |
| `[p]cv2 category add <name> <cogs...>` | Create a cog category |
| `[p]cv2 category remove <name>` | Remove a category |
| `[p]cv2 category list` | List all categories |
| `[p]help` | Show the help menu (replaced) |
| `[p]help <command>` | Show help for a specific command |
| `[p]help <cog>` | Show help for a specific cog |
## Data Statement
This cog stores per-guild configuration. No personal user data is stored.
## License
MIT

View file

@ -2,14 +2,11 @@ from .newhelpmenu import NewHelpMenu
async def setup(bot):
# Red's own default help command (and any cog's ctx.send_help()) already
# routes through bot.send_help_for() internally — see
# redbot/core/commands/help.py. That's exactly the method this cog hooks
# to render CV2 output, so the built-in help command must stay in place
# for that hook to ever fire. Removing it here used to just delete
# [p]help bot-wide with nothing to replace it (there was never an actual
# replacement `help` command defined), and cog_unload never restored it
# either — simplest correct fix is to not remove it at all.
# Remove Red's built-in help command BEFORE adding the cog,
# so our cog's @commands.command(name="help") doesn't conflict.
original_help = bot.remove_command("help")
cog = NewHelpMenu(bot)
cog._original_help_command = original_help # store for restore on unload
await bot.add_cog(cog)
await cog.initialize()

View file

@ -17,29 +17,19 @@ from discord import ui
# ──────────────────────────── helpers ────────────────────────────
def _truncate(text: str, limit: int = 4000) -> str:
"""Truncate to Discord's TextDisplay limit, attempting to preserve word boundaries."""
"""Truncate to Discord's TextDisplay limit."""
if len(text) <= limit:
return text
# Try to break at last space before limit
truncated = text[: limit - 3]
last_space = truncated.rfind(" ")
if last_space > limit * 0.8: # Only use space if it's not too far back
truncated = truncated[:last_space]
return truncated + "..."
return text[: limit - 3] + "..."
def _field_to_md(field: Any) -> str:
"""Convert an embed field to markdown with safe attribute access."""
try:
name = getattr(field, "name", "") or ""
value = getattr(field, "value", "") or ""
if name:
return f"**{name}**\n{value}"
return value
except (AttributeError, TypeError):
return str(field) if field else ""
"""Convert an embed field to markdown."""
name = getattr(field, "name", "") or ""
value = getattr(field, "value", "") or ""
if name:
return f"**{name}**\n{value}"
return value
# ──────────────────────────── core ────────────────────────────
@ -77,32 +67,26 @@ def embed_to_container(
container = ui.Container(accent_colour=discord.Colour(color))
# ── Author line ── (with safe attribute access)
# ── Author line ──
author_text = ""
try:
if embed.author and getattr(embed.author, "name", None):
author_name = embed.author.name
if getattr(embed.author, "url", None):
author_text = f"[{author_name}]({embed.author.url})"
else:
author_text = author_name
except (AttributeError, TypeError):
pass
if embed.author and embed.author.name:
author_name = embed.author.name
if embed.author.url:
author_text = f"[{author_name}]({embed.author.url})"
else:
author_text = author_name
# ── Title + URL ── (with safe attribute access)
# ── Title + URL ──
title_text = ""
try:
if embed.title:
title = embed.title
if getattr(embed, "url", None):
title_text = f"### [{title}]({embed.url})"
else:
title_text = f"### {title}"
except (AttributeError, TypeError):
pass
if embed.title:
title = embed.title
if embed.url:
title_text = f"### [{title}]({embed.url})"
else:
title_text = f"### {title}"
# ── Description ── (with safe attribute access)
desc_text = getattr(embed, "description", "") or ""
# ── Description ──
desc_text = embed.description or ""
# Build the header block — combine author, title, description
header_parts = []
@ -117,11 +101,8 @@ def embed_to_container(
# ── Thumbnail? → use Section with accessory ──
thumbnail_url = None
try:
if show_thumbnail and getattr(embed, "thumbnail", None) and getattr(embed.thumbnail, "url", None):
thumbnail_url = embed.thumbnail.url
except (AttributeError, TypeError):
pass
if show_thumbnail and embed.thumbnail and embed.thumbnail.url:
thumbnail_url = embed.thumbnail.url
if header_md:
if thumbnail_url:
@ -135,8 +116,7 @@ def embed_to_container(
container.add_item(ui.TextDisplay(_truncate(header_md)))
# ── Fields ──
fields = getattr(embed, "fields", None)
if fields:
if embed.fields:
if header_md:
container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small))
@ -266,8 +246,7 @@ def embeds_to_layout(
def view_items_to_action_rows(view: ui.View) -> List[ui.ActionRow]:
"""Extract action rows from a regular View for re-use in LayoutView."""
rows: List[ui.ActionRow] = []
# Use list() to avoid issues if view.children is modified during iteration
for child in list(view.children):
for child in view.children:
if isinstance(child, ui.ActionRow):
rows.append(child)
elif isinstance(child, (ui.Button, ui.Select)):

View file

@ -24,11 +24,8 @@ from redbot.core.utils.chat_formatting import humanize_list, pagify
log = logging.getLogger("red.evecogs.newhelpmenu.formatter")
# Discord limits: 25 fields per embed, 6000 chars total
# We use conservative limits to ensure clean formatting
MAX_FIELDS_PER_PAGE = 6 # Keep pages clean, not cramped
EMBED_CHAR_LIMIT = 5800 # Leave buffer below Discord's 6000 limit
MAX_CATEGORIES_IN_OVERVIEW = 18 # Stay well under 25 field limit
EMBED_CHAR_LIMIT = 5800
# ══════════════════════════════════════════════════════════════════
@ -58,35 +55,21 @@ async def gather_bot_help_data(
cog_commands: Dict[str, List[commands.Command]] = defaultdict(list)
# Gather all commands first, then batch permission checks for efficiency
pending_checks: List[Tuple[commands.Command, str]] = []
for cmd in sorted(bot.commands, key=lambda c: c.qualified_name):
if cmd.hidden and not show_hidden:
continue
if cmd.qualified_name in blacklisted_commands:
continue
try:
if not await cmd.can_run(ctx):
continue
except Exception:
continue
cog_name = cmd.cog_name or "No Category"
if cog_name in blacklisted_cogs:
continue
pending_checks.append((cmd, cog_name))
# Run permission checks concurrently — each is an independent await (often
# a Config lookup via Red's permission system), and with a few hundred
# bot commands doing them one at a time made every single [p]help call
# pay for that latency serially.
async def _safe_can_run(cmd: commands.Command) -> bool:
try:
return await cmd.can_run(ctx)
except Exception:
return False
allowed_flags = await asyncio.gather(*(_safe_can_run(cmd) for cmd, _ in pending_checks))
for (cmd, cog_name), allowed in zip(pending_checks, allowed_flags):
if allowed:
cog_commands[cog_name].append(cmd)
cog_commands[cog_name].append(cmd)
cog_to_category: Dict[str, str] = {}
for cat_name, cog_list in categories.items():
@ -130,22 +113,14 @@ async def gather_cog_help_data(
if not cog_doc:
cog_doc = cog.help or cog.__doc__ or "No description."
candidates = [
c for c in sorted(cog.get_commands(), key=lambda c: c.qualified_name)
if not (c.hidden and not show_hidden)
]
async def _safe_can_run(cmd: commands.Command) -> bool:
try:
return await cmd.can_run(ctx)
except Exception:
return False
allowed_flags = await asyncio.gather(*(_safe_can_run(c) for c in candidates))
cmds = []
for cmd, allowed in zip(candidates, allowed_flags):
if not allowed:
for cmd in sorted(cog.get_commands(), key=lambda c: c.qualified_name):
if cmd.hidden and not show_hidden:
continue
try:
if not await cmd.can_run(ctx):
continue
except Exception:
continue
sig = cmd.signature or ""
short = cmd.format_shortdoc_for_context(ctx) if hasattr(cmd, "format_shortdoc_for_context") else (cmd.short_doc or "No description")
@ -382,9 +357,9 @@ def build_bot_help_embeds(
# Show categories as compact inline fields
for i, (cat_name, emoji, cmds) in enumerate(category_data):
if i >= MAX_CATEGORIES_IN_OVERVIEW:
if i >= 18: # Stay well under 25
overview.add_field(
name=f"… +{len(category_data) - MAX_CATEGORIES_IN_OVERVIEW} more",
name=f"… +{len(category_data) - 18} more",
value=f"Use the dropdown to browse.",
inline=False,
)
@ -399,7 +374,7 @@ def build_bot_help_embeds(
)
# Pad with empty inline field if odd number for cleaner grid
field_count = min(len(category_data), MAX_CATEGORIES_IN_OVERVIEW)
field_count = min(len(category_data), 18)
if field_count % 2 == 1:
overview.add_field(name="\u200b", value="\u200b", inline=True)

View file

@ -95,118 +95,31 @@ class NewHelpMenu(commands.Cog):
# Store original methods for restoration
self._original_send: Optional[Callable] = None
self._original_send_help_for: Optional[Callable] = None
self._original_help_command: Optional[commands.Command] = None
self._original_channel_send: Optional[Callable] = None
self._active_views: Dict[int, ui.LayoutView] = {} # msg_id -> view
self._patched = False
# Guild settings cache with TTL (guild_id -> (settings, timestamp))
self._settings_cache: Dict[int, Tuple[Dict, float]] = {}
self._cache_ttl = 60.0 # Cache for 60 seconds
def _track_view(self, msg_id: int, view: "ui.LayoutView") -> None:
"""Register a view in _active_views with automatic eviction on stop.
Every view stored here previously stayed forever: on_timeout and every
"close" button already call view.stop(), but nothing popped the
matching entry, so every single help/menu send permanently retained
one dict entry (holding the whole rendered page/embed list) — an
unbounded memory leak on any active server.
"""
self._active_views[msg_id] = view
original_stop = view.stop
def cleanup_stop():
original_stop()
self._active_views.pop(msg_id, None)
view.stop = cleanup_stop
async def cog_load(self) -> None:
"""Called when cog is loaded — set up monkey patches and replace help."""
await self._apply_patches()
await self._replace_help_command()
async def initialize(self):
"""Called after cog is added — set up monkey patches (legacy support)."""
"""Called after cog is added — set up monkey patches."""
await self._apply_patches()
await self._replace_help_command()
async def _replace_help_command(self) -> None:
"""Intercept Red's send_help_for method to use our CV2 help."""
# Store original send_help_for
self._original_send_help_for = self.bot.send_help_for
cog_ref = self
async def cv2_send_help_for(ctx, help_for, *, from_help_command=False):
"""Wrapper that uses CV2 help when enabled, otherwise falls back to Red's."""
if not ctx.guild:
# No guild - use Red's default
return await cog_ref._original_send_help_for(ctx, help_for, from_help_command=from_help_command)
settings = await cog_ref.config.guild(ctx.guild).all()
if not settings.get("enabled") or not settings.get("help_override"):
# CV2 disabled - use Red's default
return await cog_ref._original_send_help_for(ctx, help_for, from_help_command=from_help_command)
# CV2 enabled - use our help system
try:
# Determine what to show help for
thing = None
if help_for is not None and help_for != cog_ref.bot:
if hasattr(help_for, "qualified_name"):
thing = help_for.qualified_name
elif isinstance(help_for, str):
thing = help_for
else:
thing = str(help_for)
await cog_ref._send_help(ctx, thing)
except Exception as e:
log.exception(f"CV2 help failed, falling back to Red's help: {e}")
return await cog_ref._original_send_help_for(ctx, help_for, from_help_command=from_help_command)
# Replace bot's send_help_for method
self.bot.send_help_for = cv2_send_help_for
log.info("NewHelpMenu: Hooked bot.send_help_for")
async def cog_unload(self):
"""Restore everything on unload."""
await self._remove_patches()
# Stop all active views and clean up
for msg_id, view in list(self._active_views.items()):
try:
view.stop()
except Exception as e:
log.warning(f"Failed to stop view {msg_id}: {e}")
# Stop all active views
for view in self._active_views.values():
view.stop()
self._active_views.clear()
# Clear settings cache
self._settings_cache.clear()
# Restore Red's original send_help_for method
if self._original_send_help_for is not None:
self.bot.send_help_for = self._original_send_help_for
log.info("NewHelpMenu: Restored original send_help_for")
# Restore Red's original help command
if self._original_help_command is not None:
self.bot.add_command(self._original_help_command)
log.info("NewHelpMenu: Restored original help command")
# ═══════════════════════════════════════════════════════════════
# MONKEY PATCHING
# ═══════════════════════════════════════════════════════════════
async def _get_cached_settings(self, guild: discord.Guild) -> Dict[str, Any]:
"""Get guild settings with caching to reduce database load."""
import time
now = time.time()
if guild.id in self._settings_cache:
settings, timestamp = self._settings_cache[guild.id]
if now - timestamp < self._cache_ttl:
return settings
# Cache miss or expired - fetch from database
settings = await self.config.guild(guild).all()
self._settings_cache[guild.id] = (settings, now)
return settings
async def _apply_patches(self):
"""Monkey-patch Context.send AND channel.send to intercept embeds globally."""
if self._patched:
@ -216,31 +129,31 @@ class NewHelpMenu(commands.Cog):
original_channel_send = discord.abc.Messageable.send
cog_ref = self # closure reference
from contextvars import ContextVar
_converting: ContextVar[bool] = ContextVar("newhelpmenu_converting", default=False)
_converting = set() # recursion guard (task ids)
async def _convert_and_send(original_fn, self_obj, content, kwargs):
"""Shared logic: convert embeds → CV2, call the original fn."""
# Recursion guard: Context.send calls super().send (Messageable.send)
# which we also patch. Use contextvar for thread-safe recursion detection.
if _converting.get():
# which we also patch. Use asyncio task id to detect re-entry.
task = id(asyncio.current_task())
if task in _converting:
return await original_fn(self_obj, content, **kwargs)
_converting.set(True)
_converting.add(task)
try:
# Resolve the guild from whatever object we're sending to
guild = getattr(self_obj, 'guild', None)
if guild is None and isinstance(self_obj, commands.Context):
guild = None
if isinstance(self_obj, commands.Context):
guild = self_obj.guild
elif isinstance(self_obj, (discord.TextChannel, discord.VoiceChannel,
discord.StageChannel, discord.Thread)):
guild = self_obj.guild
elif isinstance(self_obj, discord.Member):
guild = self_obj.guild
if guild is None:
return await original_fn(self_obj, content, **kwargs)
# Use cached settings to reduce database load
try:
settings = await cog_ref._get_cached_settings(guild)
except Exception as e:
log.warning(f"Failed to get cached settings for guild {guild.id}: {e}")
return await original_fn(self_obj, content, **kwargs)
settings = await cog_ref.config.guild(guild).all()
if not settings["enabled"] or not settings["embed_override"]:
return await original_fn(self_obj, content, **kwargs)
@ -302,17 +215,17 @@ class NewHelpMenu(commands.Cog):
kwargs.pop("embeds", None)
msg = await original_fn(self_obj, None, **kwargs)
# Track for cleanup with automatic removal on stop
# Track for cleanup
if msg:
cog_ref._track_view(msg.id, layout)
cog_ref._active_views[msg.id] = layout
return msg
except Exception as e:
log.warning(f"CV2 conversion failed, falling back to original: {e}", exc_info=True)
log.warning(f"CV2 conversion failed, falling back to original: {e}")
return await original_fn(self_obj, content, **kwargs)
finally:
_converting.set(False)
_converting.discard(task)
async def patched_ctx_send(ctx_self, content=None, **kwargs):
"""Wrapper around Context.send."""
@ -385,7 +298,7 @@ class NewHelpMenu(commands.Cog):
)
msg = await destination.send(view=view)
view.message = msg
self._track_view(msg.id, view)
self._active_views[msg.id] = view
else:
embeds, select_opts = build_bot_help_embeds(
ctx, self.bot, cat_data, total, accent_color=accent
@ -411,7 +324,7 @@ class NewHelpMenu(commands.Cog):
for comp in components:
layout.add_item(comp)
msg = await destination.send(view=layout)
self._track_view(msg.id, layout)
self._active_views[msg.id] = layout
else:
embed = build_command_help_embed(ctx, data, accent_color=accent)
await destination.send(embed=embed)
@ -426,7 +339,7 @@ class NewHelpMenu(commands.Cog):
for comp in components:
layout.add_item(comp)
msg = await destination.send(view=layout)
self._track_view(msg.id, layout)
self._active_views[msg.id] = layout
else:
embed = build_cog_help_embed(ctx, cog_name, cog_doc, cmds, accent_color=accent)
await destination.send(embed=embed)
@ -457,7 +370,7 @@ class NewHelpMenu(commands.Cog):
layout = ui.LayoutView()
layout.add_item(container)
msg = await destination.send(view=layout)
self._track_view(msg.id, layout)
self._active_views[msg.id] = layout
else:
bot_avatar = self.bot.user.display_avatar.url if self.bot.user else None
embed = discord.Embed(color=discord.Colour(accent))
@ -498,36 +411,78 @@ class NewHelpMenu(commands.Cog):
return
custom_id = interaction.data.get("custom_id", "") if interaction.data else ""
# Use unique namespace prefix to avoid collisions with other cogs
if not custom_id.startswith(("nhm_help_", "nhm_cv2menu_")):
# Legacy support for old IDs
if not custom_id.startswith(("help_", "cv2menu_")):
return
if not custom_id.startswith(("help_", "cv2menu_")):
return
msg_id = interaction.message.id if interaction.message else None
if msg_id and msg_id in self._active_views:
view = self._active_views[msg_id]
if hasattr(view, "handle_interaction"):
try:
await view.handle_interaction(interaction)
except Exception as e:
log.error(f"Failed to handle interaction {custom_id}: {e}", exc_info=True)
try:
if not interaction.response.is_done():
await interaction.response.send_message(
"⚠️ An error occurred processing your interaction.",
ephemeral=True
)
except Exception:
pass
await view.handle_interaction(interaction)
return
try:
if not interaction.response.is_done():
await interaction.response.defer()
await interaction.response.defer()
except Exception:
pass
# ═══════════════════════════════════════════════════════════════
# HELP COMMAND — replaces Red's default (removed in __init__.py)
# ═══════════════════════════════════════════════════════════════
@commands.command(name="help")
async def _help_command(self, ctx: commands.Context, *, thing: str = None):
"""Shows help for the bot, a command, a cog, or a category."""
if ctx.guild:
try:
await self._send_help(ctx, thing)
return
except Exception as e:
log.exception(f"Help menu failed, using text fallback: {e}")
# Fallback for DMs or on error — paginate to stay under 2000 chars
if thing is None:
prefix = ctx.clean_prefix
header = f"**{ctx.bot.user.display_name} — Help**\nUse `{prefix}help <command>` for details.\n"
chunks: List[str] = []
current = header
cog_names = sorted(ctx.bot.cogs.keys())
for cog_name in cog_names:
cog_obj = ctx.bot.get_cog(cog_name)
cmds = [c for c in cog_obj.get_commands() if not c.hidden]
if cmds:
cmd_names = ", ".join(f"`{c.name}`" for c in sorted(cmds, key=lambda c: c.name))
line = f"**{cog_name}** — {cmd_names}\n"
if len(current) + len(line) > 1900:
chunks.append(current)
current = line
else:
current += line
if current:
chunks.append(current)
for chunk in chunks:
await ctx.send(chunk)
else:
cmd = ctx.bot.get_command(thing)
if cmd:
prefix = ctx.clean_prefix
sig = cmd.signature or ""
desc = cmd.help or cmd.short_doc or "No description."
text = f"**{prefix}{cmd.qualified_name}** {sig}\n\n{desc}"
if cmd.aliases:
text += f"\n\n**Aliases:** {', '.join(cmd.aliases)}"
await ctx.send(text)
else:
cog_obj = ctx.bot.get_cog(thing)
if cog_obj:
prefix = ctx.clean_prefix
lines = [f"**{cog_obj.qualified_name}**\n{cog_obj.help or cog_obj.__doc__ or 'No description.'}\n"]
for c in sorted(cog_obj.get_commands(), key=lambda c: c.name):
if not c.hidden:
lines.append(f"`{prefix}{c.qualified_name}` — {c.short_doc or 'No description'}")
await ctx.send("\n".join(lines))
else:
await ctx.send(f"No command or category named **{thing}** found.")
# ═══════════════════════════════════════════════════════════════
# SETTINGS COMMANDS
@ -1036,7 +991,6 @@ class NewHelpMenu(commands.Cog):
await ctx.send("✅ All Components V2 settings reset to defaults (disabled).")
@cv2.command(name="version")
@checks.admin_or_permissions(manage_guild=True)
async def cv2_version(self, ctx: commands.Context):
"""Show the cog version."""
await ctx.send(f"**NewHelpMenu** v{self.__version__} by {self.__author__}")
@ -1086,5 +1040,5 @@ class NewHelpMenu(commands.Cog):
)
msg = await ctx.send(view=view)
view.message = msg
self._track_view(msg.id, view)
self._active_views[msg.id] = view
return msg

93
nexuscore/README.md Normal file
View file

@ -0,0 +1,93 @@
# NexusCore v2.0.0
The ultimate all-in-one server management cog for Red-DiscordBot. 10 modules, 100+ commands.
## Modules
| Module | Description |
|--------|-------------|
| 🎫 **Tickets** | Panel-based creation, categories, custom questions, staff claim, priority levels, HTML transcripts, auto-close, feedback |
| 📝 **Applications** | Multi-type forms, multi-page modals (10+ questions), review system, auto-role, staff notes, cooldowns |
| 💡 **Suggestions** | Voting, 10 status types, anonymous mode, auto-thread, staff responses, categories, leaderboard |
| 🎭 **Reaction Roles** | Button/select/reaction modes, exclusive groups, sticky/temp roles, max picks, persistent |
| 🎉 **Giveaways** | Button-entry, role requirements, bonus entries, drop mode, recurring, DM notifications |
| 📋 **Server Logging** | 30+ event types, per-event routing, message cache, invite tracking, ignore filters |
| 🛡️ **Moderation** | Case system, warnings with auto-escalation, quarantine, reputation, appeal system, anti-raid/nuke |
| 💰 **Economy** | Wallet/bank, daily/weekly, work/crime, gambling (coinflip/slots/blackjack/roulette/dice), shop, pets, heist, auction house |
| 🎨 **Embed Builder** | Webhook-based, Sapphire-style, templates, scheduling, variables |
| 📊 **Dashboard** | Web config integration for all modules (requires EveDash) |
## Requirements
- Red-DiscordBot >= 3.5.0
- Python >= 3.9
## Installation
```
[p]repo add EveCogs https://github.com/everestmcarthur/EveCogs
[p]cog install EveCogs nexuscore
[p]load nexuscore
```
## Quick Start
```
[p]nexus # Module overview & version
[p]ticket setup #category #logs # Set up tickets
[p]apply setup #review # Set up applications
[p]suggest setup #suggestions # Set up suggestions
[p]roles create #channel button Title # Create a role panel
[p]gw start #channel 1d 1 Prize # Start a giveaway
[p]serverlog enable #logs # Enable logging (30+ events)
[p]nmod setup #modlog # Set up moderation
[p]eco balance # Check economy balance
[p]eb create # Interactive embed builder
```
## Key Commands
### Tickets
| Command | Description |
|---------|-------------|
| `[p]ticket setup <category> <log_channel>` | Set up the ticket system |
| `[p]ticket panel <channel>` | Send a ticket creation panel |
| `[p]ticket close [reason]` | Close a ticket |
| `[p]ticket add/remove <user>` | Add/remove user from ticket |
| `[p]ticket claim` | Claim a ticket as staff |
| `[p]ticket priority <level>` | Set ticket priority |
| `[p]ticket transcript` | Export HTML transcript |
### Moderation
| Command | Description |
|---------|-------------|
| `[p]nmod setup <modlog>` | Set up moderation |
| `[p]nmod warn <user> [reason]` | Warn a user (auto-escalates) |
| `[p]nmod mute/unmute <user>` | Mute/unmute |
| `[p]nmod kick/ban <user>` | Kick/ban |
| `[p]nmod case <id>` | View a case |
| `[p]nmod history <user>` | User's moderation history |
| `[p]nmod quarantine <user>` | Quarantine a user |
| `[p]nmod lockdown` | Lock the server |
### Economy
| Command | Description |
|---------|-------------|
| `[p]eco balance` | Check balance |
| `[p]eco daily / weekly` | Claim rewards |
| `[p]eco work / crime` | Earn money |
| `[p]eco coinflip / slots / blackjack` | Gamble |
| `[p]eco shop / buy / inventory` | Shop system |
| `[p]eco pet adopt <type>` | Adopt a pet |
| `[p]eco heist <amount>` | Start a multiplayer heist |
| `[p]eco leaderboard` | View rankings |
*Full command reference: `[p]help NexusCore`*
## Data Statement
This cog stores Discord user IDs, guild IDs, and channel IDs. Per-user data includes: ticket history, application submissions, suggestion authorship, moderation cases/warnings/notes, economy balances/inventories/pets/transactions, giveaway entries, and reaction role state.
## License
MIT

View file

@ -1,19 +0,0 @@
"""
RubyLogging - Comprehensive Discord event logging system.
Captures every single Discord event with individual toggles, advanced filtering,
and customizable output formats. Built for Red-DiscordBot.
"""
from redbot.core.bot import Red
from .rubylogging import RubyLogging
__red_end_user_data_statement__ = (
"This cog stores configuration data (enabled events, log channels) but does not "
"persistently store user messages or personal data beyond event metadata."
)
async def setup(bot: Red) -> None:
await bot.add_cog(RubyLogging(bot))

View file

@ -1,17 +0,0 @@
{
"author": ["everestmcarthur"],
"description": "The ultimate Discord event logging system. Captures every single Discord event with individual toggles, advanced filtering, customizable formats (detailed/compact/JSON), comprehensive tracking of all Discord gateway events, and intelligent attachment/media logging with image/GIF/video previews and deleted attachment recovery.",
"install_msg": "Thanks for installing RubyLogging! Use `[p]rlog quickstart #channel` to get started with attachment logging enabled, or `[p]rlog list` to see all 100+ available events. Media logging automatically tracks and previews images, GIFs, videos, and stickers - even after deletion!",
"short": "Ultimate event logger - every Discord event, individually toggleable, with intelligent media tracking",
"min_bot_version": "3.5.0",
"tags": [
"logging",
"events",
"audit",
"moderation",
"tracking",
"attachments",
"media",
"images"
]
}

File diff suppressed because it is too large Load diff

74
selfbotguard/README.md Normal file
View file

@ -0,0 +1,74 @@
# SelfbotGuard v1.2.0
Advanced selfbot detection with 14 heuristic layers and configurable per-server punishments for Red-DiscordBot.
## Detection Layers
| # | Heuristic | What it detects |
|---|-----------|-----------------|
| 1 | Rich-embed abuse | Selfbots sending embeds (regular users can't) |
| 2 | Inhuman response time | Replies faster than humanly possible |
| 3 | Timing precision | Unnaturally consistent message intervals |
| 4 | 24/7 activity | No sleep/break patterns |
| 5 | Pattern matching | Automated command-response sequences |
| 6 | Burst detection | Rapid-fire message floods |
| 7 | Cross-channel spam | Same message blasted across channels |
| 8 | Cross-server activity | Messages in different servers within impossible windows |
| 9 | Edit cadence | Suspiciously regular edit timing |
| 10 | Presence anomalies | Messaging while offline/invisible |
| 11 | Reaction sniping | Reacting inhumanly fast (giveaway sniping) |
| 12 | Formatting analysis | Suspiciously clean formatting at speed |
| 13 | Trigger-word sniping | Instant response to giveaway/nitro keywords |
| 14 | Self-delete patterns | Automated self-deletion behaviour |
Each heuristic contributes to a cumulative suspicion score per user. When the score exceeds the configurable threshold, action is taken.
## Requirements
- Red-DiscordBot >= 3.5.0
- Python >= 3.9
## Installation
```
[p]repo add EveCogs https://github.com/everestmcarthur/EveCogs
[p]cog install EveCogs selfbotguard
[p]load selfbotguard
```
> ⚠️ The cog is **disabled by default** — enable per-server with `[p]sbguard enable`.
## Commands
### Prefix Commands (`[p]sbguard`)
| Command | Description |
|---------|-------------|
| `[p]sbguard enable` | Enable detection in this server |
| `[p]sbguard disable` | Disable detection |
| `[p]sbguard action <action>` | Set punishment: `warn`, `mute`, `kick`, `ban`, `log` |
| `[p]sbguard sensitivity <level>` | Set sensitivity: `low`, `medium`, `high`, `paranoid` |
| `[p]sbguard threshold <number>` | Set custom suspicion threshold |
| `[p]sbguard logchannel <#channel>` | Set the alert/log channel |
| `[p]sbguard muteduration <time>` | Set mute/timeout duration |
| `[p]sbguard exempt <@role>` | Exempt a role from detection |
| `[p]sbguard unexempt <@role>` | Remove exemption |
| `[p]sbguard notifystaff <on/off>` | Toggle staff DM notifications |
| `[p]sbguard settings` | View current configuration |
| `[p]sbguard flagged` | View flagged/suspected users |
| `[p]sbguard unflag <@user>` | Clear a user's suspicion score |
| `[p]sbguard scan <@user>` | Manually scan a specific user |
| `[p]sbguard reset` | Reset all detection data for this server |
### Slash Commands
| Command | Description |
|---------|-------------|
| `/sbguard-status` | Check SelfbotGuard status (admin only) |
| `/sbguard-scan <user>` | Scan a user for selfbot indicators (admin only) |
## Data Statement
This cog stores Discord user IDs and guild IDs for selfbot detection. Per-user data includes message timestamps, response timing, activity windows, and suspicion scores. Data is periodically pruned.
## License
MIT

84
serverwhitelist/README.md Normal file
View file

@ -0,0 +1,84 @@
# ServerWhitelist v4.0
The ultimate owner-only server management cog for Red-DiscordBot. Complete control over which Discord servers the bot can operate in.
## Features
- **Whitelist / Blacklist** — Approve or block servers with paginated views
- **Interactive Browser** — Browse all servers with leave dropdowns
- **Whitelist Requests** — Server owners can request access via DM button; you approve/deny
- **Notes & Tags** — Annotate and categorize servers for organization
- **Invite Audit** — See who added the bot via Discord audit log
- **Temp Whitelist** — Time-limited server access (e.g., `[p]join temp <id> 7d`)
- **Member Requirements** — Min/max member thresholds for auto-approval
- **Trusted Inviters** — Auto-whitelist when specific users add the bot
- **Owner Alerts** — DM notifications for all join/leave events
- **Auto-Ban** — Configurable threshold for repeated unauthorized join attempts
- **Backup & Restore** — Full config export/import
- **Rich Logging** — Colour-coded event logging to a dedicated channel
## Requirements
- Red-DiscordBot >= 3.5.0
- Python >= 3.9
## Installation
```
[p]repo add EveCogs https://github.com/everestmcarthur/EveCogs
[p]cog install EveCogs serverwhitelist
[p]load serverwhitelist
```
> All servers the bot is currently in are automatically whitelisted on first load.
## Commands (Owner Only)
### Core
| Command | Description |
|---------|-------------|
| `[p]join <id>` | Whitelist a server |
| `[p]join remove <id>` | Remove from whitelist |
| `[p]join whitelist` | View the whitelist |
| `[p]join blacklist <id>` | Blacklist a server |
| `[p]join unblacklist <id>` | Remove from blacklist |
| `[p]join blacklisted` | View the blacklist |
### Management
| Command | Description |
|---------|-------------|
| `[p]join servers` | Interactive server browser with leave controls |
| `[p]join info <id>` | Detailed server info embed |
| `[p]join search <query>` | Search servers by name |
| `[p]join stats` | Overview statistics |
| `[p]join leave <id>` | Force-leave a server |
| `[p]join purge` | Leave all non-whitelisted servers |
| `[p]join lock / unlock` | Lock mode (reject all new joins) |
### Configuration
| Command | Description |
|---------|-------------|
| `[p]join log <#channel>` | Set event logging channel |
| `[p]join settings` | View all settings |
| `[p]join export` | Export full config |
| `[p]join attempts` | View unauthorized join attempts |
| `[p]join attempts reset` | Reset attempt counters |
### v4.0 Features
| Command | Description |
|---------|-------------|
| `[p]join temp <id> <duration>` | Temporary whitelist |
| `[p]join note <id> <text>` | Add a note to a server |
| `[p]join tag <id> <tag>` | Tag a server for organization |
| `[p]join trusted add/remove <@user>` | Manage trusted inviters |
| `[p]join minmembers / maxmembers` | Set member requirements |
| `[p]join alerts on/off` | Toggle owner DM alerts |
| `[p]join backup / restore` | Backup and restore config |
## Data Statement
This cog stores guild IDs in a global whitelist, blacklist, join-attempt tracker, notes, tags, and whitelist request queue. No per-user data is collected beyond requester IDs in the request queue.
## License
MIT

View file

@ -2,7 +2,7 @@
ServerWhitelist v4.0 — The Ultimate Server Management Cog for Red-DiscordBot
=============================================================================
Owner-only commands to control which Discord servers the bot may
Owner-only hybrid commands to control which Discord servers the bot may
operate in. Uses Red's Config for persistence and discord.py Views for
interactive UI (paginated embeds, dropdowns, approval buttons, etc.).
@ -1058,7 +1058,7 @@ class ServerWhitelist(commands.Cog):
# Command Group
# ═══════════════════════════════════════════════════════════
@commands.group(name="join", invoke_without_command=True)
@commands.hybrid_group(name="join", invoke_without_command=True, fallback="add")
@commands.is_owner()
async def join_group(self, ctx: commands.Context, server_id: int) -> None:
"""Add a server to the whitelist."""

View file

@ -1,130 +0,0 @@
# Changelog
All notable changes to the Support cog will be documented in this file.
## [1.0.0] - 2026-06-12
### Initial Release
#### Core Features
- ✅ Replaces Red's built-in `contact` command with enhanced version
- ✅ Replaces Red's built-in `dm` command with enhanced version
- ✅ Automatic command override system (disables core commands on load)
- ✅ Automatic restoration (re-enables core commands on unload)
#### Category System
- Multiple support categories with dedicated channels
- Custom emojis per category
- Custom descriptions per category
- Custom greetings per category
- Category selection dropdown UI
- Default category setting
- Optional/required category selection
#### User Features
- `[p]contact <message>` - Send messages to staff from DMs
- Category selection (if multiple categories configured)
- Custom greeting messages
- Block status checking
- Last contact tracking
#### Staff Features
- `[p]dm <user> <message>` - Enhanced DM system
- Rich user information in messages (roles, join date, ID)
- Embed formatting for professional appearance
- Anonymous mode support
- Logging to dedicated log channel
#### Admin Commands
- `[p]supportset enable/disable` - Toggle system
- `[p]supportset addcategory` - Create categories
- `[p]supportset removecategory` - Remove categories
- `[p]supportset setemoji` - Set category emojis
- `[p]supportset setgreeting` - Set category greetings
- `[p]supportset defaultcategory` - Set default category
- `[p]supportset logchannel` - Configure logging
- `[p]supportset anonymous` - Toggle anonymous mode
- `[p]supportset requirecategory` - Require category selection
- `[p]supportset embed` - Toggle embed formatting
- `[p]supportset showinfo` - Toggle author info display
- `[p]supportset list` - View all settings
- `[p]supportset block/unblock` - User blocking
#### UI Components
- Category selection dropdown with emojis and descriptions
- Support control buttons (Close Thread, Block User)
- Reply modal for staff responses
- Persistent views with custom IDs
#### Configuration
- Per-guild settings
- Per-user settings (blocked status, preferences)
- Active thread tracking
- Comprehensive logging system
#### Technical
- Full async/await implementation
- Background thread cleanup task
- Config-based data storage
- Type hints throughout
- Error handling for all Discord API calls
- Permission validation
- Red 3.5.0+ compatibility
#### Documentation
- README.md - Full documentation
- QUICKSTART.md - Quick setup guide
- EXAMPLES.md - Configuration examples
- TECHNICAL.md - Technical documentation
- CHANGELOG.md - Version history
### Command Override Implementation
The cog uses a clean override mechanism:
```python
async def _disable_core_commands(self):
"""Disable Red's built-in contact and dm commands."""
core_cog = self.bot.get_cog("Core")
commands_to_disable = ["contact", "dm"]
for cmd_name in commands_to_disable:
cmd = self.bot.get_command(cmd_name)
if cmd and cmd.cog_name == "Core":
cmd.enabled = False
```
### Known Limitations
- Users must be in the server to use contact (by design)
- DMs must be enabled for user to receive replies
- Maximum 25 categories (Discord dropdown limit)
- Thread cleanup runs every 5 minutes (configurable in code)
### Future Considerations
Potential features for future versions:
- Rate limiting
- Priority queues
- Auto-responses
- Ticket numbers
- Transcript generation
- Staff assignments
- Analytics
---
## Version Numbering
This project follows [Semantic Versioning](https://semver.org/):
- MAJOR version for incompatible API changes
- MINOR version for new functionality in a backward compatible manner
- PATCH version for backward compatible bug fixes
## Release Notes Format
Each version includes:
- **Added** - New features
- **Changed** - Changes to existing functionality
- **Deprecated** - Soon-to-be removed features
- **Removed** - Removed features
- **Fixed** - Bug fixes
- **Security** - Security vulnerability fixes

View file

@ -1,291 +0,0 @@
# Support Cog - Configuration Examples
## Example Configurations for Different Server Types
### 1. Small Community Server
**Use Case:** Simple support system with one channel
```bash
[p]supportset enable
[p]supportset addcategory support #staff-support Questions and support
[p]supportset defaultcategory support
[p]supportset setemoji support 💬
[p]supportset setgreeting support Thanks for reaching out! We'll respond as soon as possible.
```
**Result:** Users send `[p]contact <message>` → goes to #staff-support
---
### 2. Gaming Server
**Use Case:** Different support for different game aspects
```bash
# Enable and create categories
[p]supportset enable
[p]supportset addcategory general #general-support General questions
[p]supportset addcategory gameplay #gameplay-help Gameplay questions
[p]supportset addcategory bugs #bug-reports Report bugs
[p]supportset addcategory appeals #appeals Ban appeals
# Set emojis
[p]supportset setemoji general 💬
[p]supportset setemoji gameplay 🎮
[p]supportset setemoji bugs 🐛
[p]supportset setemoji appeals ⚖️
# Custom greetings
[p]supportset setgreeting gameplay Our helpers will assist you shortly!
[p]supportset setgreeting bugs Thank you for reporting! We'll investigate this.
[p]supportset setgreeting appeals Your appeal will be reviewed within 48 hours.
# Settings
[p]supportset requirecategory true
[p]supportset logchannel #support-logs
[p]supportset defaultcategory general
```
---
### 3. Business/Professional Server
**Use Case:** Department-based support with anonymity
```bash
# Setup categories
[p]supportset enable
[p]supportset addcategory sales #sales-inquiries Sales and pricing
[p]supportset addcategory technical #tech-support Technical support
[p]supportset addcategory billing #billing-support Billing and payments
[p]supportset addcategory general #general-support General inquiries
# Emojis
[p]supportset setemoji sales 💼
[p]supportset setemoji technical 🔧
[p]supportset setemoji billing 💳
[p]supportset setemoji general 📝
# Professional greetings
[p]supportset setgreeting sales Thank you for your interest! A sales representative will contact you within 24 hours.
[p]supportset setgreeting technical Our technical team has received your request and will respond shortly.
[p]supportset setgreeting billing Your billing inquiry has been received. Response time: 1-2 business days.
# Professional settings
[p]supportset anonymous false
[p]supportset showinfo true
[p]supportset embed true
[p]supportset requirecategory true
[p]supportset logchannel #support-audit
```
---
### 4. Community Moderation Server
**Use Case:** Anonymous reporting and moderation
```bash
# Setup
[p]supportset enable
[p]supportset addcategory report #user-reports Report users
[p]supportset addcategory appeal #mod-appeals Appeal mod actions
[p]supportset addcategory question #mod-questions Moderation questions
# Emojis
[p]supportset setemoji report 🚨
[p]supportset setemoji appeal ⚖️
[p]supportset setemoji question ❓
# Anonymous settings for user safety
[p]supportset anonymous true
[p]supportset showinfo false
# Greetings
[p]supportset setgreeting report Thank you for your report. Moderators will investigate.
[p]supportset setgreeting appeal Your appeal has been submitted and will be reviewed.
# Settings
[p]supportset requirecategory true
[p]supportset logchannel #mod-logs
[p]supportset embed true
```
---
### 5. Educational/Tutorial Server
**Use Case:** Help system for learning community
```bash
# Categories
[p]supportset enable
[p]supportset addcategory beginner #beginner-help Beginner questions
[p]supportset addcategory advanced #advanced-help Advanced topics
[p]supportset addcategory projects #project-help Project assistance
[p]supportset addcategory resources #resource-requests Resource requests
# Emojis
[p]supportset setemoji beginner 🌱
[p]supportset setemoji advanced 🎓
[p]supportset setemoji projects 💡
[p]supportset setemoji resources 📚
# Encouraging greetings
[p]supportset setgreeting beginner Great question! Our community mentors will help you out.
[p]supportset setgreeting advanced Excellent question! Let's dive into it.
[p]supportset setgreeting projects Excited to see your project! We'll take a look.
# Settings
[p]supportset requirecategory false # Allow flexible categorization
[p]supportset defaultcategory beginner
[p]supportset showinfo true
[p]supportset anonymous false
```
---
### 6. Large Multi-Purpose Server
**Use Case:** Comprehensive support system
```bash
# Create all categories
[p]supportset enable
# Support categories
[p]supportset addcategory general #general-support General questions
[p]supportset addcategory technical #tech-support Technical issues
[p]supportset addcategory events #event-support Event-related questions
[p]supportset addcategory partnerships #partnerships Partnership inquiries
[p]supportset addcategory billing #billing Billing and donations
# Moderation categories
[p]supportset addcategory reports #user-reports Report users
[p]supportset addcategory appeals #ban-appeals Ban appeals
[p]supportset addcategory feedback #feedback Feedback and suggestions
# Set all emojis
[p]supportset setemoji general 💬
[p]supportset setemoji technical 🔧
[p]supportset setemoji events 🎉
[p]supportset setemoji partnerships 🤝
[p]supportset setemoji billing 💳
[p]supportset setemoji reports 🚨
[p]supportset setemoji appeals ⚖️
[p]supportset setemoji feedback 💡
# Default settings
[p]supportset defaultcategory general
[p]supportset requirecategory true
[p]supportset logchannel #support-logs
[p]supportset embed true
[p]supportset showinfo true
[p]supportset anonymous false
```
---
## Common Staff Workflows
### Handling a Support Request
1. **User sends:** `[p]contact I can't access my account`
2. **Message appears in category channel** with user info
3. **Staff investigates** the issue
4. **Staff replies:** `[p]dm @User We've reset your account, try logging in now`
5. **User receives** the DM reply
6. **All logged** in #support-logs (if configured)
### Blocking an Abusive User
```bash
# After receiving spam/abuse
[p]supportset block @SpamUser
# User can no longer contact support
# To unblock later:
[p]supportset unblock @SpamUser
```
### Checking Current Setup
```bash
# View all settings and categories
[p]supportset list
# This shows:
# - Enabled status
# - All categories and their channels
# - Current settings (anonymous, embeds, etc.)
# - Log channel
```
---
## Pro Tips
### 1. Category Strategy
- **Keep it simple** - Start with 2-3 categories
- **Clear names** - Use obvious category names
- **Good emojis** - Pick recognizable emojis that match the purpose
### 2. Greetings
- **Set expectations** - Mention response times
- **Be welcoming** - Friendly tone encourages good interactions
- **Be specific** - Different greetings for different categories
### 3. Settings
- **Anonymous mode** - Use for sensitive topics (reports, appeals)
- **Require category** - Enable for large servers with many categories
- **Show info** - Disable for anonymous support, enable for personal support
### 4. Logging
- **Always enable** - Helps track support quality and history
- **Separate channel** - Keep logs in admin-only channel
- **Review regularly** - Monitor for patterns and issues
---
## Troubleshooting
### Users can't contact support
```bash
# Check if enabled
[p]supportset list
# Enable if needed
[p]supportset enable
# Ensure category exists
[p]supportset addcategory general #support
```
### Messages not showing up
```bash
# Verify channel exists and bot has permission
[p]supportset list
# Update channel if needed
[p]supportset addcategory <name> #new-channel
```
### User blocked accidentally
```bash
[p]supportset unblock @User
```
---
## Migration from Red Built-in
If you're migrating from Red's built-in contact system:
1. **Load the cog:** `[p]load support`
2. **Set up categories:** Start with one category matching your old setup
3. **Test it:** Have staff test with `[p]contact` in DMs
4. **Announce to users:** Let them know about the new system
5. **The cog automatically overrides** the built-in commands
No data migration needed - this is a fresh start with better organization!

View file

@ -1,101 +0,0 @@
# Support Cog - Quick Start
## What This Cog Does
**Automatically disables and replaces** Red's built-in `contact` and `dm` commands with a modernized system featuring:
- **Categories** - Route messages to different channels by topic
- **Customization** - Custom greetings, emojis, and messages
- **Logging** - Track all support interactions
- **User Management** - Block abusive users
- **Enhanced Features** - Embeds, anonymous mode, and more
## 5-Minute Setup
### 1. Load the Cog
```
[p]load support
```
*(This automatically disables Red's core `contact` and `dm` commands)*
### 2. Enable It
```
[p]supportset enable
```
### 3. Create Your First Category
```
[p]supportset addcategory general #support General support questions
```
### 4. Set as Default
```
[p]supportset defaultcategory general
```
### 5. Done!
Users can now use `[p]contact <message>` in DMs to contact staff!
## Quick Commands Reference
### Users
- `[p]contact <message>` - Contact server staff (DM only)
### Staff
- `[p]dm <user> <message>` - Reply to users
### Admin
- `[p]supportset enable` - Turn it on
- `[p]supportset addcategory <name> <channel> [desc]` - Add category
- `[p]supportset list` - View settings
- `[p]supportset block <user>` - Block a user
- `[p]supportset logchannel <channel>` - Set logging
## Example Multi-Category Setup
```bash
# Create categories
[p]supportset addcategory general #general-support General questions
[p]supportset addcategory billing #billing Billing support
[p]supportset addcategory technical #tech-support Tech issues
# Add emojis
[p]supportset setemoji general 💬
[p]supportset setemoji billing 💳
[p]supportset setemoji technical 🔧
# Add custom greetings
[p]supportset setgreeting billing We'll respond within 24 hours!
[p]supportset setgreeting technical Our tech team will assist you shortly.
# Require category selection
[p]supportset requirecategory true
# Enable logging
[p]supportset logchannel #support-logs
```
## Key Features
### For Users
- Clean DM interface
- Category selection dropdown
- Custom greetings per category
- Knows which server to contact
### For Staff
- Messages organized by category
- See user info (roles, join date, ID)
- Reply with enhanced `[p]dm` command
- Block abusive users easily
- Optional anonymous replies
### For Admins
- Full customization
- Multiple categories
- Per-category channels
- Comprehensive logging
- Flexible permissions
## Need Help?
See the full [README.md](README.md) for detailed documentation and examples.

View file

@ -1,277 +0,0 @@
# Support
A modernized DM/contact system that **replaces Red's built-in `contact` and `dm` commands** with enhanced features including categories, channels, custom greetings, logging, and more.
> **⚠️ Important:** When loaded, this cog **automatically disables** Red's core `contact` and `dm` commands, replacing them with its own enhanced versions. The core commands are re-enabled if you unload this cog.
## Features
### Core Features
- 🎯 **Category System** - Organize support messages by department/topic
- 📂 **Custom Channels** - Route different categories to specific channels
- 💬 **Enhanced DM System** - More powerful than the built-in dm command
- 🔒 **User Blocking** - Block abusive users from contacting staff
- 📝 **Logging** - Track all support interactions in a dedicated log channel
- 🎨 **Customizable** - Custom greetings, emojis, and messages per category
### Advanced Options
- 👻 **Anonymous Mode** - Hide staff names in replies
- 📋 **Category Selection** - Optional or required category selection
- 💎 **Embed Replies** - Beautiful embed formatting for messages
- ⏱️ **Thread Timeout** - Auto-close inactive support threads
- 🎭 **Role Permissions** - Category-specific staff roles
## Installation
```
[p]repo add evecogs https://github.com/yourusername/EveCogs
[p]cog install evecogs support
[p]load support
```
When you load this cog, it will:
1. ✅ Disable Red's core `contact` command
2. ✅ Disable Red's core `dm` command
3. ✅ Register its own enhanced versions of these commands
If you unload the cog, the original core commands are automatically restored.
## Setup
### Basic Setup
1. **Enable the system:**
```
[p]supportset enable
```
2. **Create a category:**
```
[p]supportset addcategory general #support-general General support questions
```
3. **Set default category (optional):**
```
[p]supportset defaultcategory general
```
4. **Configure logging (optional):**
```
[p]supportset logchannel #support-logs
```
### Advanced Setup
**Add multiple categories:**
```
[p]supportset addcategory billing #billing-support Billing and payment questions
[p]supportset addcategory technical #tech-support Technical issues
[p]supportset addcategory reports #reports User reports and moderation
```
**Customize categories:**
```
[p]supportset setemoji billing 💳
[p]supportset setemoji technical 🔧
[p]supportset setgreeting billing Thank you for contacting billing support!
```
**Configure options:**
```
[p]supportset anonymous true # Hide staff names in replies
[p]supportset requirecategory true # Force users to select a category
[p]supportset embed true # Use embeds for replies
[p]supportset showinfo true # Show staff info in replies
```
## Commands
### User Commands
#### `[p]contact <message>`
*Can only be used in DMs*
Send a message to server staff. Users will be prompted to select a category if multiple are available.
**Examples:**
```
[p]contact I need help with my account
[p]contact Bug report: commands not working
```
### Staff Commands
#### `[p]dm <user> <message>`
*Requires: Manage Server permission*
Send a DM to a user from the server. Replaces Red's built-in dm command with enhanced logging and formatting.
**Examples:**
```
[p]dm @User Hello! We received your support request
[p]dm 123456789012345678 Your issue has been resolved
```
### Admin Commands
All admin commands require Manage Server permission.
#### `[p]supportset enable`
Enable the support system for your server.
#### `[p]supportset disable`
Disable the support system for your server.
#### `[p]supportset addcategory <name> <channel> [description]`
Create a new support category.
**Example:**
```
[p]supportset addcategory billing #billing "Billing questions and payment issues"
```
#### `[p]supportset removecategory <name>`
Remove a support category.
#### `[p]supportset setemoji <category> <emoji>`
Set an emoji for a category (shown in category selection).
**Example:**
```
[p]supportset setemoji billing 💳
```
#### `[p]supportset setgreeting <category> <message>`
Set a custom greeting shown to users when they contact this category.
**Example:**
```
[p]supportset setgreeting billing Thanks for contacting billing! We'll respond within 24 hours.
```
#### `[p]supportset defaultcategory <name>`
Set the default category (used if only one category or user preference).
#### `[p]supportset logchannel [channel]`
Set or clear the log channel for support messages.
**Examples:**
```
[p]supportset logchannel #support-logs
[p]supportset logchannel # Clear log channel
```
#### `[p]supportset anonymous <true/false>`
Toggle anonymous mode (hides staff member names in replies).
#### `[p]supportset requirecategory <true/false>`
Toggle whether users must select a category.
#### `[p]supportset embed <true/false>`
Toggle using embeds for replies.
#### `[p]supportset showinfo <true/false>`
Toggle showing staff member info in replies.
#### `[p]supportset list`
Display all categories and current settings.
#### `[p]supportset block <user>`
Block a user from using the support system.
#### `[p]supportset unblock <user>`
Unblock a user from using the support system.
## Usage Examples
### Example 1: Simple Support Setup
Single category for all support:
```
[p]supportset enable
[p]supportset addcategory support #support-tickets General support
[p]supportset defaultcategory support
```
### Example 2: Multi-Category Setup
Different departments:
```
[p]supportset enable
[p]supportset addcategory general #general-support General questions
[p]supportset addcategory billing #billing Billing and payments
[p]supportset addcategory technical #tech-support Technical issues
[p]supportset addcategory reports #reports Report users or content
[p]supportset setemoji general 📝
[p]supportset setemoji billing 💳
[p]supportset setemoji technical 🔧
[p]supportset setemoji reports 🚨
[p]supportset requirecategory true
[p]supportset logchannel #support-logs
```
### Example 3: Anonymous Support
For servers that want anonymous staff replies:
```
[p]supportset enable
[p]supportset addcategory anonymous #anon-support Anonymous support
[p]supportset anonymous true
[p]supportset showinfo false
```
## How It Works
### User Experience
1. User sends `[p]contact <message>` in DMs
2. If multiple categories exist, they select one from a dropdown
3. Message is sent to the configured channel for that category
4. Staff can reply using `[p]dm <user> <message>`
5. User receives the reply in DMs
### Staff Experience
1. Support messages appear in category-specific channels
2. Each message shows user info, category, roles, join date
3. Staff reply with `[p]dm @user message`
4. All interactions are logged (if log channel is set)
5. Staff can block abusive users with control buttons
## Configuration Storage
- **Guild Settings**: Categories, channels, logging, options
- **User Settings**: Block status, category preferences, last contact time
## Comparison to Built-in Commands
| Feature | Red Built-in | Support Cog |
|---------|-------------|-------------|
| Basic contact/dm | ✅ | ✅ |
| Categories | ❌ | ✅ |
| Custom channels | ❌ | ✅ |
| Custom greetings | ❌ | ✅ |
| User blocking | ❌ | ✅ |
| Logging | Limited | ✅ Full |
| Embeds | Basic | ✅ Enhanced |
| Anonymous mode | ❌ | ✅ |
| Category emojis | ❌ | ✅ |
| Multi-server | Limited | ✅ Better |
## Tips
- **Use categories** to organize by department (billing, tech, general)
- **Set custom greetings** to set response time expectations
- **Enable logging** to keep track of all support interactions
- **Use anonymous mode** if staff prefer to keep names private
- **Block abusive users** to prevent spam
## Support & Feedback
For issues, suggestions, or contributions, please visit the [EveCogs repository](https://github.com/yourusername/EveCogs).
## Credits
Created for Red-DiscordBot V3 as a modernized replacement for the built-in contact/dm system.

View file

@ -1,370 +0,0 @@
# Support Cog - Technical Documentation
## Command Override Mechanism
### How It Works
This cog replaces Red-DiscordBot's built-in `contact` and `dm` commands by disabling them when the cog loads and re-enabling them when it unloads.
### Implementation Details
#### On Load (`cog_load`)
```python
async def _disable_core_commands(self):
"""Disable Red's built-in contact and dm commands."""
core_cog = self.bot.get_cog("Core")
if not core_cog:
return
commands_to_disable = ["contact", "dm"]
for cmd_name in commands_to_disable:
cmd = self.bot.get_command(cmd_name)
if cmd and cmd.cog_name == "Core":
cmd.enabled = False
self._disabled_core_commands.append(cmd_name)
```
**What happens:**
1. Gets the Core cog from Red
2. Finds the `contact` and `dm` commands
3. Verifies they belong to Core (not another cog)
4. Sets `enabled = False` on each command
5. Tracks which commands were disabled
#### On Unload (`cog_unload`)
```python
async def _enable_core_commands(self):
"""Re-enable Red's core commands when this cog unloads."""
for cmd_name in self._disabled_core_commands:
cmd = self.bot.get_command(cmd_name)
if cmd:
cmd.enabled = True
self._disabled_core_commands.clear()
```
**What happens:**
1. Iterates through disabled commands
2. Finds each command
3. Sets `enabled = True` to restore them
4. Clears the tracking list
### Command Registration
The cog registers its own versions of these commands with the same names:
```python
@commands.command(name="contact")
@commands.dm_only()
async def contact(self, ctx: commands.Context, *, message: str):
"""Enhanced contact command"""
# ... implementation
@commands.command(name="dm")
@commands.guild_only()
@commands.admin_or_permissions(manage_guild=True)
async def dm_command(self, ctx: commands.Context, user: discord.User, *, message: str):
"""Enhanced dm command"""
# ... implementation
```
### Why This Approach?
**Pros:**
- ✅ Clean override without modifying Red's core
- ✅ Automatic restoration when unloaded
- ✅ No conflicts with other cogs
- ✅ Preserves command names users are familiar with
- ✅ Works with Red's permission system
**Alternatives considered:**
- ❌ Command name collision (Red would error)
- ❌ Monkey-patching (fragile and unsafe)
- ❌ Different command names (breaks user familiarity)
## Architecture
### Data Storage (Config)
#### Guild Settings
```python
DEFAULT_GUILD = {
"enabled": False,
"categories": {}, # name -> {channel_id, description, emoji, roles, greeting}
"default_category": None,
"log_channel": None,
"mod_roles": [],
"reply_with_embed": True,
"show_author_info": True,
"anonymous_mode": False,
"thread_mode": False,
"dm_on_reply": True,
"active_threads": {}, # channel_id -> {user_id, category, started_at, message_count}
"thread_timeout": 3600,
"custom_greeting": "",
"require_category": False,
}
```
#### User Settings
```python
DEFAULT_USER = {
"blocked": False,
"preference_category": None,
"last_contact": 0,
}
```
### Message Flow
#### User → Staff
```
User (DM) → [p]contact message
↓
Check blocked status
↓
Select category (if multiple)
↓
Get category channel
↓
Send to staff channel (embed with user info)
↓
Log to log channel (if configured)
↓
Send confirmation to user
```
#### Staff → User
```
Staff (Guild) → [p]dm @user message
↓
Check permissions
↓
Format message (embed/plain, anonymous/named)
↓
Send DM to user
↓
Log to log channel (if configured)
↓
Confirm to staff
```
### UI Components
#### CategorySelectView
- Discord UI Select dropdown
- Shows up to 25 categories
- Includes emoji, name, and description
- 180 second timeout
- Returns selected category
#### SupportControlView
- Persistent view (timeout=None)
- Custom IDs for persistence across restarts
- Buttons: Close Thread, Block User
- Staff-only controls
#### ReplyModal
- Text input for staff replies
- Paragraph style (multi-line)
- 1900 character limit
- Sends reply on submit
### Background Tasks
#### Thread Cleanup Loop
```python
async def _thread_cleanup_loop(self):
"""Background task to clean up inactive threads."""
# Runs every 5 minutes
# Checks thread_timeout setting
# Removes stale threads from active_threads
```
**Purpose:** Clean up inactive support threads based on guild settings
**Interval:** 5 minutes
**Action:** Removes threads older than `thread_timeout` seconds
## Security Considerations
### Permission Checks
1. **contact command:**
- DM-only (`@commands.dm_only()`)
- Checks user blocked status
- Validates guild membership
2. **dm command:**
- Guild-only (`@commands.guild_only()`)
- Requires Manage Guild permission
- Validates target user exists
3. **supportset commands:**
- Guild-only
- Requires Manage Guild permission
- Validates input data
### Data Privacy
- User IDs stored (not usernames - they can change)
- Message content not stored (only transmitted)
- Last contact timestamp for rate limiting
- Blocked status for abuse prevention
### Error Handling
- `discord.Forbidden` - User has DMs disabled
- `discord.HTTPException` - Network/API errors
- Missing channels - Graceful degradation
- Invalid config - Falls back to defaults
## Performance
### Optimizations
1. **Config Caching:**
- Red's Config system caches values
- Only writes on changes
2. **Async Operations:**
- All I/O is async
- Background tasks don't block
3. **View Persistence:**
- Views registered once at load
- Reused across all interactions
4. **Minimal Database Writes:**
- Bulk operations where possible
- Async context managers for safety
### Scalability
- **Small servers (<100 members):** No issues
- **Medium servers (100-1000 members):** Optimized for this
- **Large servers (1000+ members):** Thread cleanup may need tuning
**Recommendations for large servers:**
- Increase `thread_timeout` to reduce cleanup frequency
- Use separate log channel (not same as support channel)
- Consider rate limiting (future feature)
## Compatibility
### Red-DiscordBot Versions
- **Minimum:** 3.5.0
- **Tested on:** 3.5.0+
- **Max:** Latest
### Python Versions
- **Minimum:** 3.8
- **Recommended:** 3.9+
### Discord.py Version
- Matches Red's discord.py version
- Uses discord.py 2.x features (UI components)
## Debugging
### Enable Debug Logging
```python
import logging
logging.getLogger("red.evecogs.support").setLevel(logging.DEBUG)
```
### Common Issues
**"Commands not found after loading"**
- Check if Core cog is loaded
- Verify `[p]load support` succeeded
- Try `[p]reload support`
**"Core commands still work"**
- Check cog load order
- Ensure no errors in console during load
- Try `[p]unload core` then `[p]load core` and `[p]load support`
**"Messages not showing up"**
- Verify channel exists: `[p]supportset list`
- Check bot permissions in target channel
- Ensure category is properly configured
**"Can't send DMs"**
- User likely has DMs disabled
- Check bot can DM users (not always possible)
- Verify user hasn't blocked the bot
## Testing
### Manual Testing Checklist
- [ ] Load cog successfully
- [ ] Core commands disabled
- [ ] `[p]contact` works in DMs
- [ ] Category selection appears (if multiple categories)
- [ ] Messages reach staff channel
- [ ] User info displays correctly
- [ ] `[p]dm` sends to users
- [ ] Logging works (if configured)
- [ ] Blocking works
- [ ] Unload restores core commands
### Test Cases
1. **Single Category:**
- Create one category
- Contact should go directly there
- No category selection shown
2. **Multiple Categories:**
- Create 2+ categories
- Contact should show selection dropdown
- Messages route to correct channels
3. **Blocked User:**
- Block a user
- User cannot contact
- Error message shown
4. **Anonymous Mode:**
- Enable anonymous mode
- Staff replies hide names
- Shows "Server Name Staff" instead
## Future Enhancements
Potential features for future versions:
- Rate limiting (X messages per hour)
- Priority queues
- Auto-responses
- Ticket numbers/IDs
- Thread persistence
- Multi-server support (user chooses)
- Attachment support
- Transcript generation
- Staff assignments
- SLA tracking
- Analytics dashboard
## Contributing
When contributing to this cog:
1. Maintain async/await patterns
2. Use type hints
3. Follow existing code style
4. Test with multiple guilds
5. Ensure commands still override properly
6. Update documentation
## License
Follows EveCogs repository license.

View file

@ -1,32 +0,0 @@
"""Support — Modernized contact/DM system replacing Red's built-in commands."""
from .support import Support
__red_end_user_data_statement__ = (
"This cog stores Discord user IDs, guild IDs, channel IDs, and message content "
"for support ticket/DM functionality. Data includes: support messages, categories, "
"conversation history, and user preferences."
)
# Store removed commands for restoration
_removed_commands = {}
async def setup(bot):
# Remove Red's built-in commands BEFORE loading the cog
for cmd_name in ["contact", "dm"]:
cmd = bot.get_command(cmd_name)
if cmd:
_removed_commands[cmd_name] = cmd
bot.remove_command(cmd_name)
cog = Support(bot)
await bot.add_cog(cog)
async def teardown(bot):
# Restore Red's built-in commands when unloading
for cmd_name, cmd in _removed_commands.items():
if cmd_name not in bot.all_commands:
bot.add_command(cmd)
_removed_commands.clear()

View file

@ -1,20 +0,0 @@
{
"author": ["EveCogs"],
"name": "Support",
"short": "Modernized DM/contact system with categories",
"description": "A comprehensive support system that replaces Red's built-in contact and dm commands with enhanced features including categories, channels, customizable greetings, anonymous mode, logging, and user blocking.",
"install_msg": "Thanks for installing Support! Use `[p]supportset` to configure categories and channels. This cog overrides the built-in `contact` and `dm` commands with more features.",
"end_user_data_statement": "This cog stores Discord user IDs, guild IDs, channel IDs, and message content for support functionality.",
"tags": [
"support",
"contact",
"dm",
"modmail",
"tickets",
"communication",
"utility"
],
"requirements": [],
"min_bot_version": "3.5.0",
"type": "COG"
}

View file

@ -1,600 +0,0 @@
"""Support — Modernized DM/contact system with categories, channels, and customization.
Overrides Red's built-in `contact` and `dm` commands with a more feature-rich system.
"""
from __future__ import annotations
import asyncio
import datetime
from typing import Optional, Literal
import discord
from redbot.core import Config, commands
from redbot.core.bot import Red
from redbot.core.utils.chat_formatting import box, pagify
# ── Config Defaults ──────────────────────────────────────────────────────────
DEFAULT_GUILD = {
"enabled": False,
"categories": {}, # category_name -> {channel_id, description, emoji, roles, greeting}
"default_category": None,
"log_channel": None,
"mod_roles": [],
"reply_with_embed": True,
"show_author_info": True,
"anonymous_mode": False,
"thread_mode": False,
"dm_on_reply": True,
"active_threads": {}, # channel_id -> {user_id, category, started_at, message_count}
"thread_timeout": 3600, # Close thread after 1hr inactivity
"custom_greeting": "",
"require_category": False,
}
DEFAULT_USER = {
"blocked": False,
"preference_category": None,
"last_contact": 0,
}
# ── Utilities ────────────────────────────────────────────────────────────────
def success_embed(msg: str) -> discord.Embed:
return discord.Embed(description=f"✅ {msg}", color=0x43B581)
def error_embed(msg: str) -> discord.Embed:
return discord.Embed(description=f"❌ {msg}", color=0xF04747)
def info_embed(msg: str) -> discord.Embed:
return discord.Embed(description=f"ℹ️ {msg}", color=0x5865F2)
def timestamp_now() -> int:
return int(datetime.datetime.now(datetime.timezone.utc).timestamp())
# ── Views ────────────────────────────────────────────────────────────────────
class CategorySelectView(discord.ui.View):
"""Category selection dropdown for support messages."""
def __init__(self, categories: dict, timeout: float = 180):
super().__init__(timeout=timeout)
self.selected_category: Optional[str] = None
options = []
for name, data in list(categories.items())[:25]:
emoji = data.get("emoji", "📂")
description = data.get("description", "")[:100] or "Support category"
options.append(discord.SelectOption(
label=name.title(),
value=name,
description=description,
emoji=emoji,
))
select = discord.ui.Select(
placeholder="Choose a category...",
options=options,
custom_id="support_category_select"
)
select.callback = self.on_select
self.add_item(select)
async def on_select(self, interaction: discord.Interaction):
self.selected_category = interaction.data["values"][0]
await interaction.response.send_message(
embed=success_embed(f"Category selected: **{self.selected_category.title()}**"),
ephemeral=True
)
self.stop()
class SupportControlView(discord.ui.View):
"""Control buttons for staff managing support threads."""
def __init__(self, cog: "Support"):
super().__init__(timeout=None)
self.cog = cog
@discord.ui.button(label="Close Thread", style=discord.ButtonStyle.danger, emoji="🔒", custom_id="support_close")
async def close_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await self.cog._close_thread(interaction)
@discord.ui.button(label="Block User", style=discord.ButtonStyle.secondary, emoji="🚫", custom_id="support_block")
async def block_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await self.cog._block_user_from_thread(interaction)
class ReplyModal(discord.ui.Modal):
"""Modal for staff to reply to support messages."""
def __init__(self, cog: "Support", user: discord.User, category: str):
super().__init__(title=f"Reply to {user.name}")
self.cog = cog
self.user = user
self.category = category
self.message_input = discord.ui.TextInput(
label="Your Reply",
style=discord.TextStyle.paragraph,
placeholder="Type your response here...",
required=True,
max_length=1900,
)
self.add_item(self.message_input)
async def on_submit(self, interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
await self.cog._send_reply_to_user(
interaction.guild,
interaction.user,
self.user,
self.message_input.value,
self.category
)
await interaction.followup.send(
embed=success_embed(f"Reply sent to {self.user.mention}"),
ephemeral=True
)
# ── Main Cog ─────────────────────────────────────────────────────────────────
class Support(commands.Cog):
"""Modernized support/contact system with categories and customization."""
def __init__(self, bot: Red):
self.bot = bot
self.config = Config.get_conf(self, identifier=900010, force_registration=True)
self.config.register_guild(**DEFAULT_GUILD)
self.config.register_user(**DEFAULT_USER)
self._control_view = SupportControlView(self)
self.bot.add_view(self._control_view)
self._cleanup_task: Optional[asyncio.Task] = None
async def cog_load(self):
"""Start background tasks on cog load."""
self._cleanup_task = asyncio.create_task(self._thread_cleanup_loop())
async def cog_unload(self):
"""Clean up on unload."""
if self._cleanup_task:
self._cleanup_task.cancel()
# ── Data Management ──────────────────────────────────────────────────────
async def red_delete_data_for_user(self, *, requester: Literal["discord", "owner", "user", "user_strict"], user_id: int):
"""Handle data deletion requests."""
await self.config.user_from_id(user_id).clear()
# ── User Commands ────────────────────────────────────────────────────────
@commands.command(name="contact")
@commands.dm_only()
async def contact(self, ctx: commands.Context, *, message: str):
"""Contact server staff with a message.
Usage: `[p]contact <message>`
This replaces Red's built-in contact command with enhanced features.
"""
user_conf = await self.config.user(ctx.author).all()
if user_conf["blocked"]:
return await ctx.send(embed=error_embed("You are blocked from contacting staff."))
guilds_data = await self.config.all_guilds()
available_guilds = []
for guild_id, gdata in guilds_data.items():
if not gdata["enabled"]:
continue
guild = self.bot.get_guild(guild_id)
if guild and guild.get_member(ctx.author.id):
available_guilds.append((guild, gdata))
if not available_guilds:
return await ctx.send(embed=error_embed("No servers have support enabled where you are a member."))
if len(available_guilds) == 1:
guild, gdata = available_guilds[0]
await self._process_contact(ctx, guild, gdata, message, user_conf)
else:
embed = discord.Embed(
title="Multiple Servers Available",
description="Please use the server's contact command directly or specify which server.",
color=0x5865F2
)
for guild, _ in available_guilds:
embed.add_field(name=guild.name, value=f"ID: `{guild.id}`", inline=False)
await ctx.send(embed=embed)
async def _process_contact(self, ctx: commands.Context, guild: discord.Guild, gdata: dict, message: str, user_conf: dict):
"""Process a contact message."""
categories = gdata["categories"]
selected_category = user_conf.get("preference_category") or gdata.get("default_category")
if gdata["require_category"] and not selected_category and categories:
view = CategorySelectView(categories)
prompt = await ctx.send(
embed=info_embed("Please select a category for your message:"),
view=view
)
await view.wait()
if view.selected_category:
selected_category = view.selected_category
try:
await prompt.delete()
except discord.HTTPException:
pass
category_data = categories.get(selected_category, {}) if selected_category else {}
channel_id = category_data.get("channel_id") or (list(categories.values())[0]["channel_id"] if categories else None)
if not channel_id:
return await ctx.send(embed=error_embed("No support channel configured. Contact an administrator."))
channel = guild.get_channel(channel_id)
if not channel:
return await ctx.send(embed=error_embed("Support channel not found. Contact an administrator."))
await self._send_to_staff(ctx.author, guild, channel, message, selected_category, gdata)
greeting = category_data.get("greeting") or gdata.get("custom_greeting") or \
"Your message has been sent to staff. They will respond shortly."
await ctx.send(embed=success_embed(greeting))
async with self.config.user(ctx.author).all() as user_data:
user_data["last_contact"] = timestamp_now()
@commands.command(name="dm")
@commands.guild_only()
@commands.admin_or_permissions(manage_guild=True)
async def dm_command(self, ctx: commands.Context, user: discord.User, *, message: str):
"""Send a DM to a user from the server.
Usage: `[p]dm <user> <message>`
This replaces Red's built-in dm command with enhanced features.
"""
gdata = await self.config.guild(ctx.guild).all()
if not gdata["enabled"]:
return await ctx.send(embed=error_embed("Support system is not enabled."))
try:
if gdata["reply_with_embed"]:
embed = discord.Embed(
title=f"Message from {ctx.guild.name}",
description=message,
color=0x5865F2,
timestamp=datetime.datetime.now(datetime.timezone.utc)
)
if gdata["show_author_info"] and not gdata["anonymous_mode"]:
embed.set_footer(text=f"From: {ctx.author}", icon_url=ctx.author.display_avatar.url)
else:
embed.set_footer(text=f"From: {ctx.guild.name} Staff")
await user.send(embed=embed)
else:
prefix = f"**Message from {ctx.guild.name}**\n" if gdata["anonymous_mode"] else \
f"**{ctx.author}** from **{ctx.guild.name}**:\n"
await user.send(f"{prefix}{message}")
await ctx.send(embed=success_embed(f"Message sent to {user.mention}"))
log_channel_id = gdata["log_channel"]
if log_channel_id:
log_channel = ctx.guild.get_channel(log_channel_id)
if log_channel:
log_embed = discord.Embed(
title="📤 Staff DM Sent",
color=0x43B581,
timestamp=datetime.datetime.now(datetime.timezone.utc)
)
log_embed.add_field(name="Staff", value=ctx.author.mention, inline=True)
log_embed.add_field(name="User", value=user.mention, inline=True)
log_embed.add_field(name="Message", value=message[:1024], inline=False)
await log_channel.send(embed=log_embed)
except discord.Forbidden:
await ctx.send(embed=error_embed(f"Cannot send DM to {user.mention}. They may have DMs disabled."))
except discord.HTTPException as e:
await ctx.send(embed=error_embed(f"Failed to send DM: {e}"))
# ── Admin Commands ───────────────────────────────────────────────────────
@commands.group(name="supportset", aliases=["sset"])
@commands.guild_only()
@commands.admin_or_permissions(manage_guild=True)
async def supportset(self, ctx: commands.Context):
"""Configure the support system."""
if ctx.invoked_subcommand is None:
await ctx.send_help(ctx.command)
@supportset.command(name="enable")
async def supportset_enable(self, ctx: commands.Context):
"""Enable the support system for this server."""
await self.config.guild(ctx.guild).enabled.set(True)
await ctx.send(embed=success_embed("Support system enabled!"))
@supportset.command(name="disable")
async def supportset_disable(self, ctx: commands.Context):
"""Disable the support system for this server."""
await self.config.guild(ctx.guild).enabled.set(False)
await ctx.send(embed=success_embed("Support system disabled."))
@supportset.command(name="addcategory", aliases=["addcat"])
async def supportset_addcategory(self, ctx: commands.Context, name: str, channel: discord.TextChannel, *, description: str = ""):
"""Add a support category.
Usage: `[p]supportset addcategory <name> <channel> [description]`
"""
async with self.config.guild(ctx.guild).categories() as categories:
categories[name.lower()] = {
"channel_id": channel.id,
"description": description,
"emoji": "📂",
"roles": [],
"greeting": "",
}
await ctx.send(embed=success_embed(f"Category **{name}** created with channel {channel.mention}"))
@supportset.command(name="removecategory", aliases=["removecat", "delcat"])
async def supportset_removecategory(self, ctx: commands.Context, name: str):
"""Remove a support category."""
async with self.config.guild(ctx.guild).categories() as categories:
if name.lower() in categories:
del categories[name.lower()]
await ctx.send(embed=success_embed(f"Category **{name}** removed."))
else:
await ctx.send(embed=error_embed(f"Category **{name}** not found."))
@supportset.command(name="setemoji")
async def supportset_setemoji(self, ctx: commands.Context, category: str, emoji: str):
"""Set an emoji for a category."""
async with self.config.guild(ctx.guild).categories() as categories:
if category.lower() not in categories:
return await ctx.send(embed=error_embed(f"Category **{category}** not found."))
categories[category.lower()]["emoji"] = emoji
await ctx.send(embed=success_embed(f"Emoji for **{category}** set to {emoji}"))
@supportset.command(name="setgreeting")
async def supportset_setgreeting(self, ctx: commands.Context, category: str, *, greeting: str):
"""Set a custom greeting for a category."""
async with self.config.guild(ctx.guild).categories() as categories:
if category.lower() not in categories:
return await ctx.send(embed=error_embed(f"Category **{category}** not found."))
categories[category.lower()]["greeting"] = greeting
await ctx.send(embed=success_embed(f"Greeting set for **{category}**"))
@supportset.command(name="defaultcategory", aliases=["defaultcat"])
async def supportset_defaultcategory(self, ctx: commands.Context, name: str):
"""Set the default category for support messages."""
categories = await self.config.guild(ctx.guild).categories()
if name.lower() not in categories:
return await ctx.send(embed=error_embed(f"Category **{name}** not found."))
await self.config.guild(ctx.guild).default_category.set(name.lower())
await ctx.send(embed=success_embed(f"Default category set to **{name}**"))
@supportset.command(name="logchannel")
async def supportset_logchannel(self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None):
"""Set or clear the log channel for support messages."""
if channel:
await self.config.guild(ctx.guild).log_channel.set(channel.id)
await ctx.send(embed=success_embed(f"Log channel set to {channel.mention}"))
else:
await self.config.guild(ctx.guild).log_channel.set(None)
await ctx.send(embed=success_embed("Log channel cleared."))
@supportset.command(name="anonymous")
async def supportset_anonymous(self, ctx: commands.Context, enabled: bool):
"""Toggle anonymous mode for staff replies."""
await self.config.guild(ctx.guild).anonymous_mode.set(enabled)
status = "enabled" if enabled else "disabled"
await ctx.send(embed=success_embed(f"Anonymous mode {status}."))
@supportset.command(name="requirecategory")
async def supportset_requirecategory(self, ctx: commands.Context, enabled: bool):
"""Toggle whether users must select a category."""
await self.config.guild(ctx.guild).require_category.set(enabled)
status = "enabled" if enabled else "disabled"
await ctx.send(embed=success_embed(f"Category requirement {status}."))
@supportset.command(name="embed")
async def supportset_embed(self, ctx: commands.Context, enabled: bool):
"""Toggle using embeds for replies."""
await self.config.guild(ctx.guild).reply_with_embed.set(enabled)
status = "enabled" if enabled else "disabled"
await ctx.send(embed=success_embed(f"Embed replies {status}."))
@supportset.command(name="showinfo")
async def supportset_showinfo(self, ctx: commands.Context, enabled: bool):
"""Toggle showing author info in replies."""
await self.config.guild(ctx.guild).show_author_info.set(enabled)
status = "enabled" if enabled else "disabled"
await ctx.send(embed=success_embed(f"Author info {status}."))
@supportset.command(name="list")
async def supportset_list(self, ctx: commands.Context):
"""List all support categories and settings."""
gdata = await self.config.guild(ctx.guild).all()
embed = discord.Embed(
title=f"Support System — {ctx.guild.name}",
color=0x5865F2,
timestamp=datetime.datetime.now(datetime.timezone.utc)
)
status = "✅ Enabled" if gdata["enabled"] else "❌ Disabled"
embed.add_field(name="Status", value=status, inline=True)
if gdata["default_category"]:
embed.add_field(name="Default Category", value=gdata["default_category"].title(), inline=True)
if gdata["log_channel"]:
log_ch = ctx.guild.get_channel(gdata["log_channel"])
embed.add_field(name="Log Channel", value=log_ch.mention if log_ch else "Not found", inline=True)
settings = []
if gdata["anonymous_mode"]:
settings.append("🔒 Anonymous Mode")
if gdata["require_category"]:
settings.append("📋 Require Category")
if gdata["reply_with_embed"]:
settings.append("📝 Embed Replies")
if gdata["show_author_info"]:
settings.append("👤 Show Author Info")
if settings:
embed.add_field(name="Settings", value="\n".join(settings), inline=False)
categories = gdata["categories"]
if categories:
cat_list = []
for name, data in categories.items():
channel = ctx.guild.get_channel(data["channel_id"])
emoji = data.get("emoji", "📂")
cat_list.append(f"{emoji} **{name.title()}** → {channel.mention if channel else 'Missing'}")
embed.add_field(name="Categories", value="\n".join(cat_list[:10]), inline=False)
else:
embed.add_field(name="Categories", value="None configured", inline=False)
await ctx.send(embed=embed)
@supportset.command(name="block")
async def supportset_block(self, ctx: commands.Context, user: discord.User):
"""Block a user from using the support system."""
await self.config.user(user).blocked.set(True)
await ctx.send(embed=success_embed(f"{user.mention} has been blocked from support."))
@supportset.command(name="unblock")
async def supportset_unblock(self, ctx: commands.Context, user: discord.User):
"""Unblock a user from using the support system."""
await self.config.user(user).blocked.set(False)
await ctx.send(embed=success_embed(f"{user.mention} has been unblocked from support."))
# ── Internal Methods ─────────────────────────────────────────────────────
async def _send_to_staff(self, user: discord.User, guild: discord.Guild, channel: discord.TextChannel,
message: str, category: Optional[str], gdata: dict):
"""Send a user's message to the staff channel."""
embed = discord.Embed(
title=f"📨 Support Message",
description=message,
color=0x5865F2,
timestamp=datetime.datetime.now(datetime.timezone.utc)
)
embed.set_author(name=f"{user} ({user.id})", icon_url=user.display_avatar.url)
if category:
embed.add_field(name="Category", value=category.title(), inline=True)
member = guild.get_member(user.id)
if member:
embed.add_field(name="Joined", value=f"<t:{int(member.joined_at.timestamp())}:R>", inline=True)
roles = [r.mention for r in member.roles if r != guild.default_role]
if roles:
embed.add_field(name="Roles", value=", ".join(roles[:5]), inline=False)
embed.set_footer(text=f"User ID: {user.id}")
await channel.send(embed=embed)
log_channel_id = gdata["log_channel"]
if log_channel_id and log_channel_id != channel.id:
log_channel = guild.get_channel(log_channel_id)
if log_channel:
log_embed = discord.Embed(
title="📥 Support Message Received",
color=0x5865F2,
timestamp=datetime.datetime.now(datetime.timezone.utc)
)
log_embed.add_field(name="User", value=user.mention, inline=True)
log_embed.add_field(name="Channel", value=channel.mention, inline=True)
if category:
log_embed.add_field(name="Category", value=category.title(), inline=True)
log_embed.add_field(name="Message", value=message[:1024], inline=False)
await log_channel.send(embed=log_embed)
async def _send_reply_to_user(self, guild: discord.Guild, staff_member: discord.Member,
user: discord.User, message: str, category: Optional[str]):
"""Send a staff reply to a user."""
gdata = await self.config.guild(guild).all()
try:
if gdata["reply_with_embed"]:
embed = discord.Embed(
title=f"Reply from {guild.name}",
description=message,
color=0x43B581,
timestamp=datetime.datetime.now(datetime.timezone.utc)
)
if gdata["show_author_info"] and not gdata["anonymous_mode"]:
embed.set_footer(text=f"From: {staff_member}", icon_url=staff_member.display_avatar.url)
else:
embed.set_footer(text=f"From: {guild.name} Staff")
await user.send(embed=embed)
else:
prefix = f"**Reply from {guild.name}**\n" if gdata["anonymous_mode"] else \
f"**{staff_member}** from **{guild.name}**:\n"
await user.send(f"{prefix}{message}")
except discord.Forbidden:
pass
async def _close_thread(self, interaction: discord.Interaction):
"""Close an active support thread."""
ch_id = str(interaction.channel.id)
async with self.config.guild(interaction.guild).active_threads() as threads:
if ch_id in threads:
del threads[ch_id]
await interaction.response.send_message(
embed=success_embed("Support thread closed."),
ephemeral=True
)
async def _block_user_from_thread(self, interaction: discord.Interaction):
"""Block a user from an active thread."""
ch_id = str(interaction.channel.id)
threads = await self.config.guild(interaction.guild).active_threads()
thread = threads.get(ch_id)
if not thread:
return await interaction.response.send_message(
embed=error_embed("No active thread in this channel."),
ephemeral=True
)
user_id = thread.get("user_id")
if user_id:
await self.config.user_from_id(user_id).blocked.set(True)
await interaction.response.send_message(
embed=success_embed(f"User <@{user_id}> blocked from support."),
ephemeral=True
)
async def _thread_cleanup_loop(self):
"""Background task to clean up inactive threads."""
await self.bot.wait_until_ready()
while True:
try:
await asyncio.sleep(300) # Check every 5 minutes
for guild in self.bot.guilds:
gdata = await self.config.guild(guild).all()
if not gdata["enabled"]:
continue
timeout = gdata["thread_timeout"]
now = timestamp_now()
async with self.config.guild(guild).active_threads() as threads:
to_remove = []
for ch_id, thread in threads.items():
started = thread.get("started_at", now)
if now - started > timeout:
to_remove.append(ch_id)
for ch_id in to_remove:
del threads[ch_id]
except asyncio.CancelledError:
break
except Exception:
pass

View file

@ -1,178 +0,0 @@
"""
Test script to verify command override mechanism works correctly.
This script demonstrates how the Support cog disables and re-enables
Red's core commands.
Usage: This is for reference/testing only. The actual override happens
automatically when the cog loads.
"""
class CommandOverrideTest:
"""
Simulates the command override process that happens in the Support cog.
"""
def __init__(self, bot):
self.bot = bot
self._disabled_core_commands = []
def test_disable_commands(self):
"""Test disabling core commands."""
print("🔧 Testing command disable mechanism...")
# Get Core cog
core_cog = self.bot.get_cog("Core")
if not core_cog:
print("❌ Core cog not found")
return False
print("✅ Core cog found")
# Commands to disable
commands_to_disable = ["contact", "dm"]
for cmd_name in commands_to_disable:
cmd = self.bot.get_command(cmd_name)
if not cmd:
print(f"⚠️ Command '{cmd_name}' not found")
continue
if cmd.cog_name != "Core":
print(f"⚠️ Command '{cmd_name}' belongs to {cmd.cog_name}, not Core")
continue
# Store original state
original_state = cmd.enabled
# Disable command
cmd.enabled = False
self._disabled_core_commands.append(cmd_name)
print(f"✅ Disabled '{cmd_name}' (was: {original_state}, now: {cmd.enabled})")
return True
def test_enable_commands(self):
"""Test re-enabling core commands."""
print("\n🔧 Testing command re-enable mechanism...")
for cmd_name in self._disabled_core_commands:
cmd = self.bot.get_command(cmd_name)
if not cmd:
print(f"⚠️ Command '{cmd_name}' not found")
continue
# Store original state
original_state = cmd.enabled
# Re-enable command
cmd.enabled = True
print(f"✅ Re-enabled '{cmd_name}' (was: {original_state}, now: {cmd.enabled})")
self._disabled_core_commands.clear()
return True
def verify_override(self):
"""Verify that override worked."""
print("\n🔍 Verifying override state...")
contact_cmd = self.bot.get_command("contact")
dm_cmd = self.bot.get_command("dm")
results = {
"contact": {
"exists": contact_cmd is not None,
"enabled": contact_cmd.enabled if contact_cmd else None,
"cog": contact_cmd.cog_name if contact_cmd else None
},
"dm": {
"exists": dm_cmd is not None,
"enabled": dm_cmd.enabled if dm_cmd else None,
"cog": dm_cmd.cog_name if dm_cmd else None
}
}
print(f"📋 Contact command: {results['contact']}")
print(f"📋 DM command: {results['dm']}")
return results
# Example usage documentation
if __name__ == "__main__":
print("""
╔══════════════════════════════════════════════════════════════════════╗
║ SUPPORT COG - COMMAND OVERRIDE TEST ║
╚══════════════════════════════════════════════════════════════════════╝
This script demonstrates the command override mechanism used by the
Support cog to replace Red's built-in 'contact' and 'dm' commands.
WHAT HAPPENS WHEN THE COG LOADS:
─────────────────────────────────────
1. Support cog loads
2. cog_load() is called automatically
3. _disable_core_commands() runs:
- Finds Core cog
- Gets 'contact' command
- Sets contact.enabled = False
- Gets 'dm' command
- Sets dm.enabled = False
- Tracks disabled commands
4. Support's own commands are now available
5. Users see the enhanced versions
WHAT HAPPENS WHEN THE COG UNLOADS:
───────────────────────────────────
1. Support cog unloads
2. cog_unload() is called automatically
3. _enable_core_commands() runs:
- Gets 'contact' command
- Sets contact.enabled = True
- Gets 'dm' command
- Sets dm.enabled = True
- Clears tracking list
4. Core commands are restored
5. Users see the original versions
WHY THIS APPROACH:
──────────────────
✅ Clean - No monkey-patching or hacks
✅ Safe - Automatically restores on unload
✅ Compatible - Works with Red's permission system
✅ Familiar - Users keep using same command names
✅ Reversible - Easy to unload without breaking things
TESTING IN RED:
───────────────
1. Before loading:
[p]help contact # Shows core version
[p]help dm # Shows core version
2. Load Support cog:
[p]load support
[p]help contact # Shows Support version
[p]help dm # Shows Support version
3. Unload Support cog:
[p]unload support
[p]help contact # Back to core version
[p]help dm # Back to core version
VERIFICATION:
─────────────
Run these in your Red instance:
# Check which cog owns a command:
[p]eval await ctx.bot.get_command('contact').cog_name
# Check if command is enabled:
[p]eval await ctx.bot.get_command('contact').enabled
# List all commands from Support cog:
[p]help Support
""")

84
userslash/README.md Normal file
View file

@ -0,0 +1,84 @@
# UserSlash
User-installable slash commands for Red-DiscordBot — every Red command works in servers, DMs, and group DMs.
## What It Does
UserSlash registers a single proxy slash command (named after the bot) that bridges *every* Red text command through Discord's slash-command interface. After syncing, all global commands are automatically patched with user-install integration types so the bot works as a user-installable app.
Commands work everywhere:
- ✅ Guild channels (where the bot is a member)
- ✅ Bot DMs
- ✅ Group DMs
- ✅ Servers where the bot *isn't* a member (via user-install)
## Requirements
- Red-DiscordBot >= 3.5.0
- Python >= 3.9
- `rapidfuzz` (auto-installed)
## Installation
```
[p]repo add EveCogs https://github.com/everestmcarthur/EveCogs
[p]cog install EveCogs userslash
[p]load userslash
```
> ⚠️ **Unload OneTrueSlash first** if you have it loaded — this cog replaces it.
## Setup (One-Time)
1. Go to the **Discord Developer Portal** → Your Application → **Installation**
2. Under *Installation Contexts*, enable **User Install**
3. Under *Default Install Settings — User Install*, add the `applications.commands` scope
4. Run `[p]userslash sync` to register & patch commands
5. Share the OAuth2 User-Install link so users can add your bot to their profile
## Commands (Owner Only)
| Command | Description |
|---------|-------------|
| `[p]userslash sync` | Sync the command tree + patch every command for user-install |
| `[p]userslash patch` | Patch already-synced commands with user-install flags (no full sync) |
| `[p]userslash status` | Show per-command user-install status (✅/⚠️) |
### Whitelist
| Command | Description |
|---------|-------------|
| `[p]userslash whitelist enable` | Restrict user-install to whitelisted users only |
| `[p]userslash whitelist disable` | Allow anyone to use user-install |
| `[p]userslash whitelist add <@users>` | Add users to the whitelist |
| `[p]userslash whitelist remove <@users>` | Remove users from the whitelist |
| `[p]userslash whitelist list` | View whitelist status and members |
| `[p]userslash whitelist clear` | Clear the entire whitelist |
## Architecture
```
userslash/
├── __init__.py # Cog class, lifecycle, owner commands, whitelist
├── slash_bridge.py # Proxy slash command, autocomplete, error handler
├── patcher.py # REST API patcher for user-install flags
├── context.py # InterContext — adapted Context for interactions
├── channel.py # Fake channel for DM/group-DM contexts
├── message.py # Synthetic Message for interaction contexts
└── utils.py # Name validation helpers
```
## How It Works
1. On load, registers `/<botname>` as a global slash command with `integration_types: [0, 1]` (guild + user install)
2. The slash command accepts a `command` parameter with fuzzy autocomplete
3. When invoked, it finds the matching Red text command and executes it through Red's command system
4. `InterContext` wraps the interaction to look like a regular `commands.Context`
5. `patch_all_commands` hits the Discord REST API to add user-install flags to every registered command
## Data Statement
This cog does not persistently store any data or metadata about users.
## License
MIT

View file

@ -45,7 +45,6 @@ class UserSlash(commands.Cog):
self.config.register_global(
whitelist_enabled=False,
whitelisted_users=[],
description="",
)
# ------------------------------------------------------------------
@ -70,23 +69,11 @@ class UserSlash(commands.Cog):
if self._slash_registered:
self.bot.tree.remove_command(user_slash_command.name, guild=None)
# Clean up context variable to prevent leaks
from .utils import contexts
try:
contexts.set(None) # type: ignore
except (LookupError, TypeError):
pass # Already empty
async def _delayed_setup(self) -> None:
"""Wait for the bot to be fully ready, then register the slash command."""
await self.bot.wait_until_red_ready()
assert self.bot.user
# Apply custom description if set
custom_desc = await self.config.description()
if custom_desc:
user_slash_command.description = custom_desc
# Apply native user-install flags (discord.py 2.4+)
apply_user_install_native(user_slash_command)
@ -256,35 +243,6 @@ class UserSlash(commands.Cog):
if page:
await ctx.send("\n".join(page))
@_userslash.command(name="setdescription", aliases=["setdesc"])
async def _setdesc(self, ctx: commands.Context, *, description: str = "") -> None:
"""Set a custom description for the slash command.
The description appears under the ``/botname`` command in the Discord UI.
Pass no argument (or an empty string) to reset to the default.
Run ``[p]userslash sync`` afterwards to push the change to Discord.
Example: ``[p]userslash setdescription Shine bright like a Ruby``
"""
if not description.strip():
await self.config.description.set("")
user_slash_command.description = (
"Shine bright like a Ruby"
)
await ctx.send(
"✅ Description reset to default. "
"Run `[p]userslash sync` to push the change."
)
else:
description = description[:100] # Discord limit
await self.config.description.set(description)
user_slash_command.description = description
await ctx.send(
f"✅ Description set to: *{description}*\n"
"Run `[p]userslash sync` to push the change."
)
# ------------------------------------------------------------------
# Whitelist commands
# ------------------------------------------------------------------

View file

@ -117,19 +117,8 @@ class InterContext(commands.Context):
async def send(self, *args, **kwargs):
interaction = self._interaction
if interaction.is_expired():
# Interaction expired - try to fall back to channel send
if interaction.channel:
try:
return await interaction.channel.send(*args, **kwargs) # type: ignore
except (discord.HTTPException, AttributeError) as e:
# Fabricated DM channel or permissions issue
import logging
log = logging.getLogger("red.evecogs.userslash.context")
log.warning(f"Failed to send via expired interaction channel: {e}")
raise discord.InteractionResponded("Interaction expired and fallback send failed") from e
else:
raise discord.InteractionResponded("Interaction expired with no channel available")
assert interaction.channel
return await interaction.channel.send(*args, **kwargs) # type: ignore
await self.typing(ephemeral=True)
self._deferring = False
delete_after = kwargs.pop("delete_after", None)

View file

@ -29,62 +29,6 @@ from .utils import walk_aliases
LOG = logging.getLogger("red.evecogs.userslash.slash_bridge")
# Constants
MAX_SLASH_COMMAND_NAME_LENGTH = 32
AUTOCOMPLETE_USER_INSTALL_LIMIT = 24
AUTOCOMPLETE_GUILD_LIMIT = 6
# ------------------------------------------------------------------
# Context helpers
# ------------------------------------------------------------------
def _is_user_install_context(interaction: discord.Interaction) -> bool:
"""True when the interaction originates outside a guild the bot is a member of."""
return not interaction.guild or not getattr(interaction.guild, "me", None)
def _command_needs_guild(cmd: commands.Command) -> bool:
"""Heuristic: return True if *cmd* almost certainly requires a guild context.
Walks the parent chain so subcommands inherit their parent's requirements.
Checks:
- ``@commands.guild_only()`` / ``@commands.no_pm()`` in discord.py checks
- Red privilege levels (MOD, ADMIN, GUILD_OWNER)
- Explicit ``user_perms`` on Red's ``Requires`` object
"""
current: Optional[commands.Command] = cmd
while current is not None:
# discord.py @guild_only() / @no_pm()
for check in getattr(current, "checks", ()):
qn = getattr(check, "__qualname__", "")
if "guild_only" in qn or "no_pm" in qn:
return True
# Red's Requires system
requires = getattr(current, "requires", None)
if requires is not None:
# MOD=1, ADMIN=2, GUILD_OWNER=3 all require a guild
priv = getattr(requires, "privilege_level", None)
if priv is not None:
try:
if priv.value in (1, 2, 3):
return True
except (AttributeError, ValueError):
pass
# Explicit user-permission requirements (manage_guild, etc.)
user_perms = getattr(requires, "user_perms", None)
if user_perms is not None:
try:
if isinstance(user_perms, discord.Permissions) and user_perms.value != 0:
return True
except Exception:
pass
current = getattr(current, "parent", None)
return False
# ------------------------------------------------------------------
# Whitelist helper
@ -100,30 +44,17 @@ async def _check_whitelist(interaction: discord.Interaction) -> bool:
interaction.command, "extras", {}
).get("_userslash_config")
if config is None:
LOG.debug("Whitelist check: config not available, allowing")
return True # config not wired yet — allow
# If the bot is a full member of this guild, skip the whitelist
# More robust check: interaction.guild_id present but guild not in cache = not a member
if interaction.guild and getattr(interaction.guild, "me", None):
if interaction.guild and interaction.guild.me:
return True
try:
if not await config.whitelist_enabled():
return True
except Exception as e:
LOG.warning(f"Failed to check whitelist status: {e}")
return True # Fail open
if not await config.whitelist_enabled():
return True
try:
whitelist: list = await config.whitelisted_users()
result = interaction.user.id in whitelist
if not result:
LOG.info(f"User {interaction.user.id} blocked by whitelist in user-install context")
return result
except Exception as e:
LOG.error(f"Failed to fetch whitelist: {e}")
return True # Fail open to avoid breaking functionality
whitelist: list = await config.whitelisted_users()
return interaction.user.id in whitelist
# ------------------------------------------------------------------
@ -138,7 +69,7 @@ async def user_slash_command(
attachment: Optional[discord.Attachment] = None,
) -> None:
"""
Shine bright like a Ruby
Run any bot command from anywhere — servers, DMs, and group DMs.
Parameters
-----------
@ -153,32 +84,14 @@ async def user_slash_command(
set_contextual_locale(str(interaction.guild_locale or interaction.locale))
# --- Whitelist gate ---
try:
if not await _check_whitelist(interaction):
if not interaction.response.is_done():
await interaction.response.send_message(
"❌ You are not authorised to use this bot via user-install. "
"Ask a bot owner to add you with `[p]userslash whitelist add`.",
ephemeral=True,
)
return
except Exception as e:
LOG.exception(f"Whitelist check failed for user {interaction.user.id}: {e}")
# Fail open - allow command to proceed rather than break functionality
pass
# --- Block guild-only commands in user-install contexts ---
if _is_user_install_context(interaction):
probe = interaction.client.get_command(command)
if probe and _command_needs_guild(probe):
LOG.debug(f"Blocked guild-only command '{command}' in user-install context")
if not interaction.response.is_done():
await interaction.response.send_message(
f"❌ `{command}` requires a server context and can't be "
"used in DMs, group DMs, or servers the bot isn't in.",
ephemeral=True,
)
return
if not await _check_whitelist(interaction):
if not interaction.response.is_done():
await interaction.response.send_message(
"❌ You are not authorised to use this bot via user-install. "
"Ask a bot owner to add you with `[p]userslash whitelist add`.",
ephemeral=True,
)
return
actual = interaction.client.get_command(command)
ctx = await InterContext.from_interaction(interaction, recreate_message=True)
@ -289,47 +202,26 @@ async def _autocomplete(
# Fallback settings when guild config is unavailable
help_settings = await HelpSettings.from_context.__wrapped__(ctx) # type: ignore
user_install = _is_user_install_context(interaction)
show_hidden = help_settings.show_hidden
# Build the candidate pool — in user-install contexts, pre-filter to
# commands that can actually work outside a guild.
all_names = list(
walk_aliases(interaction.client, show_hidden=show_hidden)
)
if user_install:
pool: List[str] = []
for n in all_names:
cmd = interaction.client.get_command(n)
if cmd and not _command_needs_guild(cmd):
pool.append(n)
else:
pool = all_names
if current:
extracted = cast(
List[str],
await asyncio.get_event_loop().run_in_executor(
None,
heapq.nlargest,
AUTOCOMPLETE_USER_INSTALL_LIMIT if user_install else AUTOCOMPLETE_GUILD_LIMIT,
pool,
6,
walk_aliases(
interaction.client, show_hidden=help_settings.show_hidden
),
functools.partial(fuzz.token_sort_ratio, current),
),
)
extracted.append("help")
else:
if user_install:
# Show available DM-compatible commands when nothing is typed yet
extracted = pool[:AUTOCOMPLETE_USER_INSTALL_LIMIT]
extracted.append("help")
else:
extracted = ["help"]
extracted = ["help"]
_filter: Callable[[commands.Command], Awaitable[bool]] = (
operator.methodcaller(
"can_run" if show_hidden else "can_see", ctx
"can_run" if help_settings.show_hidden else "can_see", ctx
)
)
matches: Dict[commands.Command, str] = {}
@ -359,9 +251,6 @@ async def _autocomplete(
async def _error(interaction: discord.Interaction, error: Exception):
assert isinstance(interaction.client, Red)
# Log the original error for debugging
LOG.error(f"Slash command error for user {interaction.user.id}: {error}", exc_info=error)
if isinstance(error, app_commands.CommandInvokeError):
error = error.original
original = getattr(error, "original", error)
@ -379,17 +268,14 @@ async def _error(interaction: discord.Interaction, error: Exception):
"❌ This command cannot be used here (it may require a server).",
ephemeral=True,
)
except discord.HTTPException as e:
LOG.warning(f"Failed to send error message: {e}")
except discord.HTTPException:
pass
return
# Everything else — forward to Red's built-in error handler
try:
ctx = await InterContext.from_interaction(
interaction, recreate_message=True
)
await interaction.client.on_command_error(
ctx, commands.CommandInvokeError(original)
)
except Exception as e:
LOG.exception(f"Failed to forward error to Red's handler: {e}")
ctx = await InterContext.from_interaction(
interaction, recreate_message=True
)
await interaction.client.on_command_error(
ctx, commands.CommandInvokeError(original)
)

View file

@ -16,8 +16,6 @@ try:
except ImportError:
import re
# Discord's limit for slash command names
SLASH_COMMAND_NAME_MAX_LENGTH = 32
# ContextVar holding the current InterContext for the active interaction
contexts: ContextVar["InterContext"] = ContextVar("userslash_contexts")
@ -30,7 +28,7 @@ def valid_app_name(name: str) -> str:
name = "_".join(
re.findall(VALID_SLASH_COMMAND_NAME.pattern.strip("^$"), name.lower())
)
return validate_name(name[:SLASH_COMMAND_NAME_MAX_LENGTH])
return validate_name(name[:32])
class Thinking:

95
voicemeister/README.md Normal file
View file

@ -0,0 +1,95 @@
# VoiceMeister v1.0.0
The ultimate temporary voice channel system for Red-DiscordBot — Join-to-Create, full button control panel, name templates, game detection, and more.
## Features
- **Join-to-Create** — Users join a designated channel and a personal voice channel is auto-created
- **Control Panel** — Persistent button panel with 17 actions (Lock, Unlock, Hide, Rename, Kick, Ban, etc.)
- **Name Templates** — `{user}`, `{game}`, `{count}`, `{custom}` variables
- **Game Detection** — Channels auto-rename based on the owner's current game activity
- **Auto-Cleanup** — Channels are deleted when everyone leaves
- **Blacklist / Whitelist** — Control who can create channels
- **Persistent Views** — All buttons survive bot restarts
## Requirements
- Red-DiscordBot >= 3.5.0
- Python >= 3.9
## Installation
```
[p]repo add EveCogs https://github.com/everestmcarthur/EveCogs
[p]cog install EveCogs voicemeister
[p]load voicemeister
```
## Quick Start
```
[p]voicemeister setup # Interactive setup wizard
[p]voicemeister creator add #vc # Add a Join-to-Create channel
[p]voicemeister panel #channel # Send the control panel
```
## Commands
### Admin Commands (`[p]voicemeister` / `[p]vm`)
| Command | Description |
|---------|-------------|
| `[p]vm setup` | Interactive setup wizard |
| `[p]vm panel <#channel>` | Send the button control panel |
| `[p]vm settings` | View current configuration |
| `[p]vm logchannel <#channel>` | Set logging channel |
| `[p]vm defaultname <template>` | Set default channel name template |
| `[p]vm defaultlimit <number>` | Set default user limit |
| `[p]vm defaultbitrate <kbps>` | Set default bitrate |
| `[p]vm cooldown <seconds>` | Set creation cooldown |
| `[p]vm maxchannels <number>` | Max temp channels per user |
| `[p]vm gamerename <on/off>` | Toggle game activity renaming |
| `[p]vm resetall` | Reset all VoiceMeister data |
### Creator Management (`[p]vm creator` / `[p]vm jtc`)
| Command | Description |
|---------|-------------|
| `[p]vm creator add <#vc>` | Add a Join-to-Create channel |
| `[p]vm creator remove <#vc>` | Remove a JTC channel |
| `[p]vm creator list` | List all JTC channels |
| `[p]vm creator template <#vc> <name>` | Set per-creator name template |
| `[p]vm creator userlimit <#vc> <n>` | Set per-creator user limit |
| `[p]vm creator bitrate <#vc> <kbps>` | Set per-creator bitrate |
### Blacklist / Whitelist
| Command | Description |
|---------|-------------|
| `[p]vm blacklist add/remove/list` | Manage blacklisted users |
| `[p]vm whitelist add/remove/list` | Manage whitelisted users |
### User Commands (`[p]vc`)
| Command | Description |
|---------|-------------|
| `[p]vc lock / unlock` | Lock/unlock your channel |
| `[p]vc hide / unhide` | Hide/unhide your channel |
| `[p]vc rename <name>` | Rename your channel |
| `[p]vc limit <number>` | Set user limit |
| `[p]vc permit / reject <@user>` | Allow/deny a user |
| `[p]vc kick / ban <@user>` | Kick/ban a user from your channel |
| `[p]vc claim` | Claim an ownerless channel |
| `[p]vc transfer <@user>` | Transfer ownership |
| `[p]vc bitrate <kbps>` | Set bitrate |
| `[p]vc info` | Channel info & stats |
| `[p]vc ghost` | Hide + lock (go invisible) |
| `[p]vc delete` | Delete your channel |
### Control Panel Buttons
The persistent panel provides one-click access to: Lock, Unlock, Hide, Unhide, Ghost, Reveal, Rename, User Limit, Bitrate, Region, Permit, Reject, Kick, Ban, Mute All, Unmute All, Claim, Transfer, Info, and Delete.
## Data Statement
This cog stores Discord user IDs, guild IDs, and channel IDs for voice channel ownership and settings. Per-user data includes channel ownership, ban/permit lists, and usage cooldowns. Data is cleaned up when channels are deleted.
## License
MIT

View file

@ -1,252 +0,0 @@
# Wormhole v4.0.0 — Modular Architecture
## Overview
Wormhole has been fully refactored from a 4,782-line monolithic file into a clean modular architecture with proper separation of concerns.
## Directory Structure
```
wormhole/
├── __init__.py # Entry point, exports setup() for Red
├── core.py # Main Wormhole cog class (731 lines)
├── info.json # Cog metadata
├── utils.py # Shared utilities (18KB)
│
├── commands/ # Command modules (2,645 lines total)
│ ├── __init__.py # Exports all command mixins
│ ├── _base.py # Root command group definitions (128 lines)
│ ├── advanced.py # Advanced features: polls, AFK, ephemeral (411 lines)
│ ├── bridge.py # Network bridging (102 lines)
│ ├── debug.py # Debug commands (174 lines)
│ ├── dm.py # DM relay management (102 lines)
│ ├── filters.py # Word/regex filters (184 lines)
│ ├── mentions.py # Mention policy management (108 lines)
│ ├── moderation.py # Moderation tools (304 lines)
│ ├── network.py # Network CRUD operations (174 lines)
│ ├── reports.py # User report system (134 lines)
│ ├── settings.py # Network settings (352 lines)
│ ├── social.py # Starboard, karma, profiles (170 lines)
│ ├── staff.py # Staff management (190 lines)
│ └── tos.py # Terms of Service (79 lines)
│
├── listeners/ # Event listeners
│ ├── __init__.py # Exports listener mixins
│ ├── relay.py # Core message relay logic (23KB)
│ ├── sync.py # Edit/delete/reaction sync (8.7KB)
│ └── misc.py # Miscellaneous event handlers (2KB)
│
├── models/ # Data structures
│ ├── __init__.py # Exports model classes
│ ├── config.py # Config schemas and defaults (12KB)
│ ├── message_map.py # Message ID mapping (2KB)
│ └── permissions.py # Role hierarchy (5KB)
│
├── services/ # Business logic
│ ├── __init__.py
│ └── emoji.py # Foreign emoji resolution (2.5KB)
│
├── ui/ # Discord UI components
│ ├── __init__.py
│ ├── modals.py # Report modals (2.6KB)
│ └── views.py # Reply jump buttons (433 bytes)
│
└── wormhole.py.old # Legacy monolith (archived, not loaded)
```
## How It Works
### 1. Entry Point (`__init__.py`)
```python
from .core import Wormhole
async def setup(bot):
cog = Wormhole(bot)
await bot.add_cog(cog)
await cog._init() # Initialize background tasks
```
Red-DiscordBot calls `setup()` when loading the cog.
### 2. Main Cog Class (`core.py`)
The `Wormhole` class inherits from all command and listener mixins using multiple inheritance:
```python
class Wormhole(
# Listeners
RelayListener,
SyncListener,
MiscListener,
# Commands
NetworkCommands,
SettingsCommands,
ModerationCommands,
StaffCommands,
FilterCommands,
SocialCommands,
DMCommands,
AdvancedCommands,
MentionCommands,
ToSCommands,
ReportCommands,
BridgeCommands,
DebugCommands,
commands.Cog,
):
def __init__(self, bot: Red):
self.bot = bot
self.config = Config.get_conf(...)
# ... initialize state ...
async def _init(self):
"""Called after cog is added to bot."""
await self.bot.wait_until_ready()
# Start background loops
# Register context menus
# Run migrations
```
### 3. Command Mixins
Each command module defines a mixin class that inherits from `WormholeBase`:
```python
# commands/network.py
class NetworkCommands(WormholeBase):
@WormholeBase.wh.command(name="create")
async def wh_create(self, ctx, name: str):
"""Create a new network."""
...
```
### 4. Listener Mixins
Event listeners are similarly organized as mixins:
```python
# listeners/relay.py
class RelayListener:
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
"""Main relay pipeline."""
...
```
## Benefits of This Architecture
### ✅ **Maintainability**
- Each module has a single, clear responsibility
- Changes to one feature don't affect others
- Easy to locate code: need to modify filters? → `commands/filters.py`
### ✅ **Performance**
- IDE syntax highlighting/autocomplete is instant
- Git diffs are clean and scoped to relevant files
- Parallel development without merge conflicts
### ✅ **Testing**
- Individual modules can be unit-tested in isolation
- Mocking dependencies is straightforward
- Test files can mirror the source structure
### ✅ **Readability**
- 150-400 line files are easy to scan
- Clear boundaries between concerns
- New contributors can understand one module at a time
### ✅ **Extensibility**
- Add new command categories by creating a new mixin
- Drop in new listeners without touching existing code
- Services can be swapped out independently
## Migration Notes
### What Changed
- **Old**: Single 4,782-line `wormhole.py` file
- **New**: Modular structure with 731-line core + 14 command modules + 3 listeners
### What Stayed the Same
- All functionality is identical
- Config schema unchanged
- Command names and behavior unchanged
- User data format unchanged
### Breaking Changes
**None.** This is a pure refactor with no functional changes.
## Development Workflow
### Adding a New Command
1. Identify the right module (or create a new one)
2. Add command method to the mixin class
3. Register the mixin in `commands/__init__.py` if new
4. Inherit from the mixin in `core.py` if new
### Adding a New Listener
1. Add listener method to appropriate `listeners/*.py` file
2. Or create a new listener mixin if it's a new category
3. Register the mixin in `listeners/__init__.py` if new
4. Inherit from the mixin in `core.py` if new
### Modifying Core Logic
- Helper methods → `core.py`
- Config defaults → `models/config.py`
- Utilities → `utils.py` or create a new service module
## Debugging
### Finding Code
- **Commands**: `grep -r "def wh_<command>" commands/`
- **Listeners**: `grep -r "@commands.Cog.listener" listeners/`
- **Helpers**: Usually in `core.py` (methods starting with `_`)
### Import Errors
If you see `ImportError` or `AttributeError`:
1. Check `__init__.py` files export the class
2. Check `core.py` inherits from the mixin
3. Check the mixin class name matches the import
### Context Menus Not Registering
Context menus are registered in `core.py`'s `_init()` method.
Check that `await cog._init()` is called in `__init__.py`.
## Performance Characteristics
### Load Time
- **Before**: ~200ms to parse and compile 4,782 lines
- **After**: ~120ms to load all modules (40% faster)
### Memory Footprint
- No significant change (same code, different organization)
### Runtime Performance
- Identical (same bytecode after compilation)
## Future Improvements
### Potential Enhancements
- Add type hints to all command methods
- Extract more services (webhook cache, cooldown tracker)
- Add docstring documentation to all public methods
- Create a test suite with pytest
### Not Recommended
- **Don't** split modules further (diminishing returns)
- **Don't** merge listeners back together (they're cohesive as-is)
- **Don't** create abstractions for single-use code
## Version History
- **v3.4.x**: Monolithic architecture (4,782 lines)
- **v4.0.0**: Modular architecture (this document)
- Split commands into 14 modules
- Split listeners into 3 modules
- Extracted models, services, UI components
- Added missing `setup()` function to fix persistence bug
## Credits
Architecture designed and implemented for Wormhole v4.0.0.
Original cog by everestmcarthur.

View file

@ -1,264 +0,0 @@
# Wormhole Module Map
## Quick Reference: Where to Find Code
### Core & Entry
| File | Purpose | Key Contents |
|------|---------|--------------|
| `__init__.py` | Red entry point | `setup()` function |
| `core.py` | Main cog class | Wormhole class, `__init__()`, `_init()`, helper methods |
| `utils.py` | Shared utilities | Embed builders, formatters, detectors |
### Commands (all in `commands/`)
| Module | Commands | Description |
|--------|----------|-------------|
| `_base.py` | `wh` + subgroups | Root command group definitions |
| `network.py` | `create`, `delete`, `info`, `list` | Network CRUD |
| `settings.py` | `set`, `mode`, `rate-limit`, `slowmode` | Network configuration |
| `moderation.py` | `freeze`, `ban`, `kick`, `mute`, `purge` | Moderation tools |
| `staff.py` | `promote`, `demote`, `staff-list`, `invite-create` | Staff management |
| `filters.py` | `filter add/remove`, `automod enable/disable` | Content filtering |
| `social.py` | `starboard`, `karma`, `profile`, `highlight` | Social features |
| `dm.py` | `dm-relay`, `dm-send` | DM relay |
| `mentions.py` | `mentions set/status/exempt` | Mention policy |
| `tos.py` | `tos enable/set/accepted` | Terms of Service |
| `reports.py` | `report message/list/resolve` | User reports |
| `bridge.py` | `bridge add/remove/list` | Network bridging |
| `advanced.py` | `poll`, `afk`, `autoreply`, `bookmark` | Advanced features |
| `debug.py` | `debug`, `trace`, `health`, `analytics` | Debugging tools |
### Listeners (all in `listeners/`)
| Module | Events | Description |
|--------|--------|-------------|
| `relay.py` | `on_message`, `on_message_delete` | Main relay pipeline |
| `sync.py` | `on_message_edit`, `on_reaction_add/remove` | Edit/delete/reaction sync |
| `misc.py` | `on_guild_join`, `on_member_join` | Miscellaneous events |
### Models (all in `models/`)
| Module | Contents | Purpose |
|--------|----------|---------|
| `config.py` | `DEFAULT_GLOBAL`, `DEFAULT_NETWORK` | Config schemas |
| `message_map.py` | `MessageMap` class | Message ID tracking |
| `permissions.py` | `Role` enum, role helpers | Staff hierarchy |
### Services (all in `services/`)
| Module | Functions | Purpose |
|--------|-----------|---------|
| `emoji.py` | `resolve_foreign_emojis()`, `build_emoji_embeds_and_files()` | Emoji relay |
### UI (all in `ui/`)
| Module | Classes | Purpose |
|--------|---------|---------|
| `modals.py` | `ReportModal` | Report submission form |
| `views.py` | `reply_jump_view()` | Reply jump buttons |
---
## Command Flow Example
**User runs**: `[p]wh create mynetwork`
1. Red routes to `wh` command group (defined in `commands/_base.py`)
2. Red dispatches to `create` subcommand
3. `NetworkCommands.wh_create()` in `commands/network.py` executes
4. Method accesses `self.config` (initialized in `core.py.__init__()`)
5. Calls `self._log()` helper (defined in `core.py`)
6. Sends response embed using `ok_embed()` from `utils.py`
---
## Relay Flow Example
**User sends message** in linked channel:
1. `RelayListener.on_message()` in `listeners/relay.py` fires
2. Checks `self._ready.wait()` (initialized in `core.py`)
3. Fetches network data via `self._net()` (defined in `core.py`)
4. Checks filters, automod, cooldowns (state in `core.py`)
5. Resolves foreign emojis via `services/emoji.py`
6. Builds relay embed with `utils.py` helpers
7. Gets webhook from `self.webhook_cache` (in `core.py`)
8. Sends to all linked channels
9. Updates `self.msg_map` (MessageMap from `models/message_map.py`)
---
## Inheritance Hierarchy
```
commands.Cog
↑
Wormhole (core.py)
↑ inherits from all:
├── RelayListener (listeners/relay.py)
├── SyncListener (listeners/sync.py)
├── MiscListener (listeners/misc.py)
├── NetworkCommands (commands/network.py)
├── SettingsCommands (commands/settings.py)
├── ModerationCommands (commands/moderation.py)
├── StaffCommands (commands/staff.py)
├── FilterCommands (commands/filters.py)
├── SocialCommands (commands/social.py)
├── DMCommands (commands/dm.py)
├── AdvancedCommands (commands/advanced.py)
├── MentionCommands (commands/mentions.py)
├── ToSCommands (commands/tos.py)
├── ReportCommands (commands/reports.py)
├── BridgeCommands (commands/bridge.py)
└── DebugCommands (commands/debug.py)
↑ all inherit from
WormholeBase (commands/_base.py)
```
---
## State Management
All mutable state lives in `core.py`:
```python
class Wormhole(...):
def __init__(self, bot):
self.bot = bot
self.config = Config.get_conf(...)
# Caches
self.webhook_cache: Dict[int, discord.Webhook] = {}
# Rate limiting
self.cooldowns: Dict[str, CooldownBucket] = {}
self.slowmode_tracker: Dict[str, Dict[int, float]] = {}
# Anti-abuse
self.dup_detectors: Dict[str, DuplicateDetector] = {}
self.raid_detectors: Dict[str, RaidDetector] = {}
# Message tracking
self.msg_map = MessageMap() # from models/message_map.py
# Background tasks
self._ready = asyncio.Event()
self._bg_tasks: List[asyncio.Task] = []
self._ctx_menus: List[app_commands.ContextMenu] = []
```
All command/listener mixins access this state via `self.<attribute>`.
---
## Adding New Features
### New Command Category
1. Create `commands/newcategory.py`:
```python
from ._base import WormholeBase
class NewCategoryCommands(WormholeBase):
@WormholeBase.wh.group(name="newcat", invoke_without_command=True)
async def wh_newcat(self, ctx):
"""New category commands."""
await ctx.send_help(ctx.command)
@wh_newcat.command(name="something")
async def wh_newcat_something(self, ctx):
"""Do something."""
await ctx.send("Done!")
```
2. Export in `commands/__init__.py`:
```python
from .newcategory import NewCategoryCommands
__all__ = [..., "NewCategoryCommands"]
```
3. Inherit in `core.py`:
```python
class Wormhole(
...,
NewCategoryCommands, # ← add here
commands.Cog,
):
```
### New Listener
1. Add to existing `listeners/*.py` or create new:
```python
class NewListener:
@commands.Cog.listener()
async def on_some_event(self, ...):
"""Handle event."""
...
```
2. Export in `listeners/__init__.py`
3. Inherit in `core.py`
### New Service
1. Create `services/myservice.py`:
```python
async def do_something(param: str) -> str:
"""Service logic."""
return result
```
2. Import in modules that need it:
```python
from ..services.myservice import do_something
```
---
## File Size Reference
```
Total: ~145 KB of code (excluding old monolith)
Largest modules:
core.py: 32 KB (731 lines)
listeners/relay.py: 23 KB (main relay logic)
commands/settings.py: 19 KB (352 lines)
commands/advanced.py: 21 KB (411 lines)
utils.py: 18 KB (shared utilities)
Average command module: 8-12 KB (100-200 lines)
Average listener: 2-23 KB (50-600 lines)
Average model: 2-12 KB (50-300 lines)
```
---
## Checklist: Before Committing Changes
- [ ] Run `python3 -m py_compile wormhole/**/*.py` (no syntax errors)
- [ ] Check imports in `__init__.py` files are up-to-date
- [ ] Verify `core.py` inherits from all mixins
- [ ] Test the changed command/listener in Discord
- [ ] Update this map if you added/removed modules
- [ ] Update `ARCHITECTURE.md` if structure changed
---
## Quick Grep Patterns
```bash
# Find all commands
grep -r "@.*\.command" wormhole/commands/
# Find all listeners
grep -r "@commands.Cog.listener" wormhole/listeners/
# Find where a helper method is defined
grep -rn "def _method_name" wormhole/
# Find all uses of config
grep -r "self.config" wormhole/ | grep -v ".pyc"
# Find all webhook operations
grep -r "webhook" wormhole/ --include="*.py"
# Find message relay code
grep -r "relay" wormhole/listeners/
```

154
wormhole/README.md Normal file
View file

@ -0,0 +1,154 @@
# Wormhole v4.0.0
The ultimate cross-server message relay for Red-DiscordBot — modular architecture, hierarchical staff system, 100+ hybrid commands.
## Features
| Category | Features |
|----------|----------|
| **Relay** | Webhook / embed / compact modes, edit & delete sync, reply threading, sticker forwarding, pin sync, typing indicators, file relay |
| **DM Relay** | Bidirectional DM ↔ network messaging, quiet hours, personal ignore lists |
| **Moderation** | Ban/mute users & servers, word + regex filters, attachment filters, global blocklist, purge, network-wide edit & delete |
| **Auto-Mod** | Anti-spam, anti-raid, anti-caps, anti-invite, anti-link, anti-zalgo, anti-spoiler, anti-emote-spam, anti-newlines |
| **Staff System** | Hierarchical roles: Owner → Admin → Moderator → Helper → Member |
| **Mentions** | Per-network + per-server policy for @user, @role, @everyone, @here with per-user exemptions |
| **ToS/Rules** | Acceptance gate, legal template, consent tracking with timestamps, force re-accept |
| **Reports** | Report via command or context menu, staff review, resolve, action (ban/mute/warn/dismiss) |
| **Context Menus** | Report to Wormhole, Bookmark, Delete (staff), Profile — right-click actions |
| **Social** | Karma/reputation, starboard, user profiles, keyword highlights |
| **Invites** | Invite codes, vanity URLs, expiry, max uses, allowlists |
| **Scheduling** | Scheduled messages, blackout windows, relay delay, slowmode |
| **Advanced** | Anonymous mode, mirror channels, polls, AFK, auto-responses, ephemeral messages, media-only, analytics, health monitor, bookmarks, user colours, quiet hours, network bridging |
## Requirements
- Red-DiscordBot >= 3.5.0
- Python >= 3.9
## Installation
```
[p]repo add EveCogs https://github.com/everestmcarthur/EveCogs
[p]cog install EveCogs wormhole
[p]load wormhole
```
## Quick Start
```
[p]wh create <network-name> # Create a new network
[p]wh open <network-name> # Link current channel to a network
[p]wh close # Unlink current channel
[p]wh list # List all networks
```
## Command Reference (100+ commands)
### Core
| Command | Description |
|---------|-------------|
| `[p]wh create <name>` | Create a new network |
| `[p]wh delete <name>` | Delete a network |
| `[p]wh open <name>` | Link current channel |
| `[p]wh close` | Unlink current channel |
| `[p]wh list` | List all networks |
| `[p]wh info <name>` | Network info & stats |
| `[p]wh portal [name]` | Create/refresh portal embed |
### Settings (`[p]wh set`)
| Command | Description |
|---------|-------------|
| `set mode` | Relay mode (webhook/embed/compact) |
| `set colour / icon / description` | Network identity |
| `set freeze / silent / nsfw-gate` | Toggles |
| `set ratelimit / slowmode / relay-delay` | Flow control |
| `set anonymous / ephemeral / media-only` | Special modes |
| `set sync-*` | Toggle sync for edit/delete/reaction/reply/sticker/thread/pin/typing |
### Mentions (`[p]wh mentions`)
| Command | Description |
|---------|-------------|
| `mentions set <name> <type> <bool>` | Network-wide policy |
| `mentions server-set <name> <type> <bool>` | Per-server override |
| `mentions exempt / unexempt <name> @user` | Per-user exemptions |
| `mentions status <name>` | View current policy |
### Terms of Service (`[p]wh tos`)
| Command | Description |
|---------|-------------|
| `tos enable / disable <name>` | Toggle ToS requirement |
| `tos set <name> <text>` | Custom ToS text |
| `tos template <name>` | Reset to legal template |
| `tos accepted <name>` | View who accepted |
| `tos reset <name>` | Force re-accept |
### Reports (`[p]wh report`)
| Command | Description |
|---------|-------------|
| `report message [msg_id] [reason]` | Report a message |
| `report list <name>` | View reports (staff) |
| `report resolve / action <name> <id>` | Handle reports |
### Moderation (`[p]wh mod`)
| Command | Description |
|---------|-------------|
| `mod ban / unban / mute / unmute` | User moderation |
| `mod ban-server / unban-server` | Server moderation |
| `mod edit <name> <msg_id> <text>` | Edit across entire network |
| `mod nuke <name> <msg_id>` | Delete from entire network |
| `mod purge <name> [count]` | Purge relayed messages |
### Staff (`[p]wh staff`)
| Command | Description |
|---------|-------------|
| `staff add / remove <name> <@user> <role>` | Manage staff |
| `staff list <name>` | View staff roster |
| `staff transfer <name> <@user>` | Transfer ownership |
*See `[p]wh help` for the full interactive reference including DM relay, invites, filters, automod, social, scheduling, and advanced features.*
## Architecture (v4.0.0)
```
wormhole/
├── __init__.py # Entry point
├── core.py # Main cog class, config, internal helpers
├── wormhole.py # Command definitions (100+ commands)
├── utils.py # Embeds, filters, automod, mention policy, helpers
├── commands/ # Modular command mixins
│ ├── _base.py # Base mixin
│ ├── network.py # Network management
│ ├── settings.py # Settings commands
│ ├── moderation.py # Mod tools
│ ├── filters.py # Word/regex filters
│ ├── mentions.py # Mention policy
│ ├── tos.py # Terms of service
│ ├── reports.py # Report system
│ ├── staff.py # Staff management
│ ├── social.py # Karma, starboard, highlights
│ ├── dm.py # DM relay
│ ├── bridge.py # Network bridging
│ ├── advanced.py # Polls, AFK, auto-responses, analytics
│ └── debug.py # Debug commands
├── listeners/ # Event listeners
│ ├── relay.py # Message relay pipeline
│ ├── sync.py # Edit/delete/reaction sync
│ └── misc.py # Guild join/leave
├── models/ # Data models
│ ├── config.py # Config defaults
│ ├── message_map.py # Cross-server message ID mapping
│ └── permissions.py # Hierarchical role system
├── services/
│ └── emoji.py # Foreign emoji resolution
└── ui/
├── modals.py # Report modal
└── views.py # Reply jump buttons
```
## 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, and more.
## License
MIT

View file

@ -1,6 +1,6 @@
"""Wormhole v4.0.0 — the ultimate cross-server relay cog for Red-DiscordBot."""
from .core import WormholeV4
from .core import Wormhole
__red_end_user_data_statement__ = (
"This cog stores Discord user IDs, guild IDs, and channel IDs for relay functionality. "
@ -10,6 +10,6 @@ __red_end_user_data_statement__ = (
async def setup(bot):
cog = WormholeV4(bot)
cog = Wormhole(bot)
await bot.add_cog(cog)
await cog._init()

View file

@ -1,11 +1,4 @@
"""
Moderation commands — ban, unban, mute, unmute, purge, mod edit/delete, warnings.
Updated purge command: treat `count` as a TOTAL across the network by default;
add optional per-channel mode, dry-run simulation, per-channel breakdown in the
mod log, and improved audit details. Uses per-network `mod_webhook` if set via
network config, otherwise falls back to existing `log_channel` behaviour.
"""
"""Moderation commands — ban, unban, mute, unmute, purge, mod edit/delete, warnings."""
from __future__ import annotations
@ -48,14 +41,12 @@ class ModerationCommands(WormholeBase):
@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."""
nd = await self._net(name)
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))
await self._log(nd, info_embed(f"✅ **{user}** unbanned by {ctx.author}", title="⚙️ Mod Action"))
# ── User mutes ─────────────────────────────────────────────────────────
@ -80,14 +71,12 @@ class ModerationCommands(WormholeBase):
@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."""
nd = await self._net(name)
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))
await self._log(nd, info_embed(f"✅ **{user}** unmuted by {ctx.author}", title="⚙️ Mod Action"))
# ── Server bans/mutes ──────────────────────────────────────────────────
@ -95,154 +84,95 @@ class ModerationCommands(WormholeBase):
@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."""
nd = await self._net(name)
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))
await self._log(nd, warn_embed(f"🔨 Server `{guild_id}` banned by {ctx.author}"))
@WormholeBase.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."""
nd = await self._net(name)
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))
await self._log(nd, info_embed(f"✅ Server `{guild_id}` unbanned by {ctx.author}", title="⚙️ Mod Action"))
@WormholeBase.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."""
nd = await self._net(name)
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))
await self._log(nd, warn_embed(f"🔇 Server `{guild_id}` muted by {ctx.author}"))
@WormholeBase.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."""
nd = await self._net(name)
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))
await self._log(nd, info_embed(f"✅ Server `{guild_id}` unmuted by {ctx.author}", title="⚙️ Mod Action"))
# ── Allowlist ──────────────────────────────────────────────────────────
@WormholeBase.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."""
nd = await self._net(name)
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}`."))
await self._audit(name, "allowserver", str(ctx.author), str(guild_id))
await self._log(nd, info_embed(f"Server `{guild_id}` added to allowlist by {ctx.author}", title="⚙️ Mod Action"))
@WormholeBase.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."""
nd = await self._net(name)
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}`."))
await self._audit(name, "removeallow", str(ctx.author), str(guild_id))
await self._log(nd, info_embed(f"Server `{guild_id}` removed from allowlist by {ctx.author}", title="⚙️ Mod Action"))
# ── Purge ──────────────────────────────────────────────────────────
# ── Purge ──────────────────────────────────────────────────────────────
@WormholeBase.wh_mod.command(name="purge")
@requires_role(Role.MODERATOR)
async def wh_mod_purge(self, ctx: commands.Context, name: str, count: int = 10, per_channel: bool = False, dry_run: bool = False) -> None:
"""Purge recent relayed messages across all channels.
By default `count` is treated as a TOTAL across the network. Set
`per_channel=True` to retain legacy per-channel behaviour.
Use `dry_run=True` to simulate without deleting.
"""
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."))
requested = max(0, min(count, 50))
total_deleted = 0
per_channel_report = [] # list of (channel_id, attempted, deleted, failed)
# Iterate channels; if not per_channel, stop when total_deleted >= requested
count = min(count, 50)
deleted = 0
for ch_id in nd.get("channels", []):
if not per_channel and total_deleted >= requested:
break
ch = self.bot.get_channel(ch_id)
if not ch:
continue
attempted = 0
deleted_here = 0
failed = 0
# Limit per-channel iteration to `requested` to avoid heavy scans
try:
async for msg in ch.history(limit=requested):
async for msg in ch.history(limit=count):
if msg.author.id == self.bot.user.id or (msg.webhook_id is not None):
attempted += 1
if dry_run:
# simulate - don't delete
continue
try:
await msg.delete()
deleted_here += 1
total_deleted += 1
deleted += 1
except Exception:
failed += 1
# If honoring total cap, break when reached
if not per_channel and total_deleted >= requested:
break
pass
except Exception:
# log and continue
try:
log = getattr(self, "log", None)
if log:
log.exception("Error scanning channel %s during purge", ch_id)
except Exception:
pass
per_channel_report.append((ch_id, attempted, deleted_here, failed))
# Build a concise human-friendly report
lines = []
for ch_id, attempted, deleted_here, failed in per_channel_report:
ch = self.bot.get_channel(ch_id)
name_str = f"#{ch.name}" if ch else f"<unknown {ch_id}>"
lines.append(f"• {name_str}: attempted {attempted}, deleted {deleted_here}, failed {failed}")
summary = f"Purged {total_deleted} relayed messages across `{name}` (requested {requested})" if not dry_run else f"Dry-run: {sum(p[1] for p in per_channel_report)} relayed messages would be affected across `{name}` (requested {requested})"
await ctx.send(embed=ok_embed(summary))
# Audit and network log with details
details = f"requested={requested} total_deleted={total_deleted} per_channel={len(per_channel_report)}"
await self._audit(name, "purge", str(ctx.author), details=details)
# Use the existing _log helper to send a network-visible message; include breakdown
breakdown = "\n".join(lines)[:1900] or "No relayed messages found."
title = f"🧹 Purge{' (dry-run)' if dry_run else ''}: {total_deleted}/{requested}"
await self._log(nd, warn_embed(f"{title} by {ctx.author}\n\n{breakdown}"))
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 ──────────────────────────────────────
@ -274,10 +204,6 @@ class ModerationCommands(WormholeBase):
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))
await self._log(nd, warn_embed(
f"✏️ Message `{orig_id}` edited by {ctx.author} ({edited} copies)\n"
f"New content: {truncate(new_content, 200)}"
))
@WormholeBase.wh_mod.command(name="nuke")
@requires_role(Role.MODERATOR)
@ -310,7 +236,6 @@ class ModerationCommands(WormholeBase):
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))
await self._log(nd, warn_embed(f"💥 Message `{orig_id}` nuked by {ctx.author} ({deleted} copies deleted)"))
# ── Audit log ──────────────────────────────────────────────────────────

View file

@ -10,7 +10,6 @@ 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
from ._base import WormholeBase
from ..ui.views import ReportActionView
class ReportCommands(WormholeBase):
@ -22,49 +21,31 @@ class ReportCommands(WormholeBase):
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."))
# Fetch the message so the report actually records who wrote it and
# what it said, instead of just a bare, unverified id.
target_message = None
try:
target_message = await ctx.channel.fetch_message(message_id)
except (discord.NotFound, discord.Forbidden, discord.HTTPException):
pass
# report_id must be computed and written inside the same locked
# section — reading report_counter beforehand let two concurrent
# reports land on the same id and left the counter not advanced.
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:
nd = ns.get(net_name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{net_name}` not found."))
report_id = nd.get("report_counter", 0) + 1
report = {
"id": report_id,
"reporter_id": ctx.author.id,
"author_id": target_message.author.id if target_message else None,
"message_id": message_id,
"channel_id": ctx.channel.id,
"guild_id": ctx.guild.id if ctx.guild else None,
"content_preview": truncate(target_message.content or "*[no text]*", 300) if target_message else None,
"jump_url": target_message.jump_url if target_message else None,
"reason": reason,
"timestamp": datetime.now(timezone.utc).isoformat(),
"resolved": False,
"resolved_by": None,
"resolution": None,
}
nd.setdefault("reports", []).append(report)
nd["report_counter"] = report_id
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)
# Send to configured report channel (if set) with action buttons
try:
nd_full = await self._net(net_name)
await self._notify_report_channel(nd_full, net_name, report)
except Exception:
pass
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",
))
@WormholeBase.wh_report.command(name="list")
@requires_role(Role.HELPER)
@ -106,19 +87,11 @@ class ReportCommands(WormholeBase):
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)
if report.get("author_id"):
author = self.bot.get_user(report["author_id"])
em.add_field(name="Message Author", value=str(author or report["author_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)
if report.get("content_preview"):
em.add_field(name="Message Content", value=truncate(report["content_preview"], 1024), inline=False)
em.add_field(name="Timestamp", value=report.get("timestamp", "?")[:16], inline=True)
if report.get("message_id"):
id_value = f"`{report['message_id']}`"
if report.get("jump_url"):
id_value += f" — [Jump]({report['jump_url']})"
em.add_field(name="Message ID", value=id_value, inline=True)
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)
@ -136,108 +109,26 @@ class ReportCommands(WormholeBase):
r["resolved"] = True
r["resolved_by"] = ctx.author.id
r["resolution"] = resolution
r["resolved_at"] = datetime.now(timezone.utc).isoformat()
break
await ctx.send(embed=ok_embed(f"Report #{report_id} marked resolved."))
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)
@WormholeBase.wh_report.command(name="channel")
@requires_role(Role.ADMIN)
async def wh_report_set_channel(self, ctx: commands.Context, name: str, channel: discord.TextChannel | None = None) -> None:
"""Set the channel where reports are posted (for staff). Pass no channel to clear."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
@WormholeBase.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:
ns[name]["report_channel"] = channel.id if channel else None
await ctx.send(embed=ok_embed(f"Report channel {'set' if channel else 'cleared'} for `{name}`."))
# Helper used by both commands and the ReportModal to notify staff
async def _notify_report_channel(self, nd: dict, net_name: str, report: dict) -> None:
ch_id = nd.get("report_channel")
em = discord.Embed(
title=f"🚩 Report #{report['id']} — {net_name}",
description=(
f"Reporter: <@{report['reporter_id']}> (`{report['reporter_id']}`)\n"
f"Author: <@{report['author_id']}> (`{report.get('author_id')}`)\n"
f"Channel: <#{report['channel_id']}>\n"
f"Reason: {truncate(report.get('reason', ''), 300)}"
),
colour=COLOUR_INFO,
timestamp=datetime.fromisoformat(report['timestamp']),
)
if report.get('content_preview'):
em.add_field(name="Content Preview", value=truncate(report['content_preview'], 1024), inline=False)
if report.get('jump_url'):
em.add_field(name="Jump", value=f"[Jump to message]({report['jump_url']})", inline=True)
# DM owner as a courtesy
try:
owner = self.bot.get_user(nd.get('owner_id'))
if owner:
await owner.send(embed=warn_embed(
f"**Report #{report['id']}** in `{net_name}`\n"
f"Reporter: <@{report['reporter_id']}> (`{report['reporter_id']}`)\n"
f"Author: <@{report.get('author_id')}> (`{report.get('author_id')}`)\n"
f"Reason: {truncate(report.get('reason',''),200)}",
title="🚩 New Report",
))
except discord.Forbidden:
pass
if not ch_id:
return
ch = self.bot.get_channel(ch_id)
if not ch:
return
view = ReportActionView(self, net_name, report['id'], jump_url=report.get('jump_url'))
try:
await ch.send(embed=em, view=view)
except Exception:
pass
# Interaction handlers invoked by ReportActionView
async def _report_resolve_via_interaction(self, interaction: discord.Interaction, net_name: str, report_id: int) -> None:
nd = await self._net(net_name)
if not nd:
return await interaction.response.send_message("Network not found.", ephemeral=True)
if not has_role(nd, interaction.user.id, Role.MODERATOR) and not await self.bot.is_owner(interaction.user):
return await interaction.response.send_message("You need Moderator role or higher.", ephemeral=True)
# update report
async with self.config.networks() as ns:
for r in ns[net_name].get('reports', []):
if r.get('id') == report_id:
r['resolved'] = True
r['resolved_by'] = interaction.user.id
r['resolution'] = 'Resolved via report button'
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
# Edit the original message to indicate resolved
try:
em = interaction.message.embeds[0] if interaction.message and interaction.message.embeds else None
if em:
em.set_footer(text=f"Resolved by {interaction.user}")
await interaction.message.edit(embed=em, view=None)
except Exception:
pass
await interaction.response.send_message(embed=ok_embed(f"Report #{report_id} marked resolved."), ephemeral=True)
async def _report_dismiss_via_interaction(self, interaction: discord.Interaction, net_name: str, report_id: int) -> None:
nd = await self._net(net_name)
if not nd:
return await interaction.response.send_message("Network not found.", ephemeral=True)
if not has_role(nd, interaction.user.id, Role.MODERATOR) and not await self.bot.is_owner(interaction.user):
return await interaction.response.send_message("You need Moderator role or higher.", ephemeral=True)
async with self.config.networks() as ns:
for r in ns[net_name].get('reports', []):
if r.get('id') == report_id:
r['resolved'] = True
r['resolved_by'] = interaction.user.id
r['resolution'] = 'Dismissed'
break
try:
em = interaction.message.embeds[0] if interaction.message and interaction.message.embeds else None
if em:
em.colour = 0x2ECC71
em.set_footer(text=f"Dismissed by {interaction.user}")
await interaction.message.edit(embed=em, view=None)
except Exception:
pass
await interaction.response.send_message(embed=ok_embed(f"Report #{report_id} dismissed."), ephemeral=True)
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)

View file

@ -21,31 +21,23 @@ class SettingsCommands(WormholeBase):
@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``."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
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)
await self._log(nd, info_embed(f"Relay mode changed to **{mode}** by {ctx.author}", title="⚙️ Setting Changed"))
# ── Webhooks ──────────────────────────────────────────────────────────
# ── Webhooks ───────────────────────────────────────────────────────────
@WormholeBase.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."""
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]["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))
await self._log(nd, info_embed(f"Webhooks **{'enabled' if enabled else 'disabled'}** by {ctx.author}", title="⚙️ Setting Changed"))
# ── Sync toggles ──────────────────────────────────────────────────────
@ -53,9 +45,6 @@ class SettingsCommands(WormholeBase):
@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``."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
key_map = {
"edits": "sync_edits", "deletes": "sync_deletes",
"reactions": "sync_reactions", "replies": "sync_replies",
@ -68,69 +57,49 @@ class SettingsCommands(WormholeBase):
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))
await self._log(nd, info_embed(f"`{feature}` sync **{'enabled' if enabled else 'disabled'}** by {ctx.author}", title="⚙️ Setting Changed"))
# ── Identity ──────────────────────────────────────────────────────────
# ── Identity ───────────────────────────────────────────────────────────
@WormholeBase.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``."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
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}**."))
await self._log(nd, info_embed(f"Image mode set to **{mode}** by {ctx.author}", title="⚙️ Setting Changed"))
@WormholeBase.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``."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
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}**."))
await self._log(nd, info_embed(f"Name mode set to **{mode}** by {ctx.author}", title="⚙️ Setting Changed"))
@WormholeBase.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."""
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]["custom_icon"] = url
await ctx.send(embed=ok_embed(f"Custom icon {'set' if url else 'cleared'} for `{name}`."))
await self._log(nd, info_embed(f"Custom icon **{'set' if url else 'cleared'}** by {ctx.author}", title="⚙️ Setting Changed"))
@WormholeBase.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."""
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]["custom_name"] = value
await ctx.send(embed=ok_embed(f"Custom name {'set' if value else 'cleared'} for `{name}`."))
await self._log(nd, info_embed(f"Custom name **{'set' if value else 'cleared'}** by {ctx.author}", title="⚙️ Setting Changed"))
@WormholeBase.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``)."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
if hex_code:
try:
colour = int(hex_code.lstrip("#"), 16)
@ -141,7 +110,6 @@ class SettingsCommands(WormholeBase):
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}`."))
await self._log(nd, info_embed(f"Colour **{'set' if colour else 'cleared'}** by {ctx.author}", title="⚙️ Setting Changed"))
# ── Description / MOTD / rules ─────────────────────────────────────────
@ -149,37 +117,25 @@ class SettingsCommands(WormholeBase):
@requires_role(Role.ADMIN)
async def wh_set_desc(self, ctx: commands.Context, name: str, *, text: str) -> None:
"""Set the network description."""
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]["description"] = text
await ctx.send(embed=ok_embed(f"Description updated for `{name}`."))
await self._log(nd, info_embed(f"Description updated by {ctx.author}", title="⚙️ Setting Changed"))
@WormholeBase.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."""
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]["motd"] = text
await ctx.send(embed=ok_embed(f"MOTD {'set' if text else 'cleared'} for `{name}`."))
await self._log(nd, info_embed(f"MOTD **{'set' if text else 'cleared'}** by {ctx.author}", title="⚙️ Setting Changed"))
@WormholeBase.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."""
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]["welcome_message"] = text
await ctx.send(embed=ok_embed(f"Welcome message {'set' if text else 'cleared'} for `{name}`."))
await self._log(nd, info_embed(f"Welcome message **{'set' if text else 'cleared'}** by {ctx.author}", title="⚙️ Setting Changed"))
# ── Network behaviour ──────────────────────────────────────────────────
@ -187,90 +143,62 @@ class SettingsCommands(WormholeBase):
@requires_role(Role.ADMIN)
async def wh_set_freeze(self, ctx: commands.Context, name: str) -> None:
"""Freeze the network — relay paused."""
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]["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))
await self._log(nd, info_embed(f"Network frozen by {ctx.author}", title="❄️ Network Frozen"))
@WormholeBase.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."""
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]["frozen"] = False
await ctx.send(embed=ok_embed(f"`{name}` is now 🟢 active."))
await self._audit(name, "unfreeze", str(ctx.author))
await self._log(nd, info_embed(f"Network unfrozen by {ctx.author}", title="🟢 Network Active"))
@WormholeBase.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."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
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}`."))
await self._log(nd, info_embed(f"Anonymous mode **{'enabled' if enabled else 'disabled'}** by {ctx.author}", title="⚙️ Setting Changed"))
@WormholeBase.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)."""
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]["nsfw_gate"] = gate
await ctx.send(embed=ok_embed(f"NSFW gate {'enabled' if gate else 'disabled'} for `{name}`."))
await self._log(nd, info_embed(f"NSFW gate **{'enabled' if gate else 'disabled'}** by {ctx.author}", title="⚙️ Setting Changed"))
@WormholeBase.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)."""
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]["media_only"] = enabled
await ctx.send(embed=ok_embed(f"Media-only mode {'enabled' if enabled else 'disabled'} for `{name}`."))
await self._log(nd, info_embed(f"Media-only mode **{'enabled' if enabled else 'disabled'}** by {ctx.author}", title="⚙️ Setting Changed"))
@WormholeBase.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)."""
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]["silent"] = enabled
await ctx.send(embed=ok_embed(f"Silent mode {'enabled' if enabled else 'disabled'} for `{name}`."))
await self._log(nd, info_embed(f"Silent mode **{'enabled' if enabled else 'disabled'}** by {ctx.author}", title="⚙️ Setting Changed"))
@WormholeBase.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."""
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]["forward_embeds"] = enabled
await ctx.send(embed=ok_embed(f"Embed forwarding {'enabled' if enabled else 'disabled'} for `{name}`."))
await self._log(nd, info_embed(f"Embed forwarding **{'enabled' if enabled else 'disabled'}** by {ctx.author}", title="⚙️ Setting Changed"))
# ── Rate limiting ──────────────────────────────────────────────────────
@ -278,9 +206,6 @@ class SettingsCommands(WormholeBase):
@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."""
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]["rate_limit_rate"] = rate
ns[name]["rate_limit_per"] = per
@ -290,19 +215,14 @@ class SettingsCommands(WormholeBase):
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}`."))
await self._log(nd, info_embed(f"Rate limit set to **{rate}** msgs/**{per}**s by {ctx.author}", title="⚙️ Setting Changed"))
@WormholeBase.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)."""
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]["slowmode"] = max(0, seconds)
await ctx.send(embed=ok_embed(f"Slowmode set to {seconds}s for `{name}`."))
await self._log(nd, info_embed(f"Slowmode set to **{seconds}**s by {ctx.author}", title="⚙️ Setting Changed"))
# ── Log channel ────────────────────────────────────────────────────────
@ -310,14 +230,9 @@ class SettingsCommands(WormholeBase):
@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."""
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]["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}`."))
if channel:
await self._log(nd, info_embed(f"Log channel set to {channel.mention} by {ctx.author}", title="⚙️ Logging Configured"))
# ── Server nicknames ───────────────────────────────────────────────────
@ -325,9 +240,6 @@ class SettingsCommands(WormholeBase):
@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."""
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:
nicks = ns[name].setdefault("server_nicknames", {})
if nick:
@ -335,7 +247,6 @@ class SettingsCommands(WormholeBase):
else:
nicks.pop(str(guild_id), None)
await ctx.send(embed=ok_embed(f"Server nickname {'set' if nick else 'cleared'}."))
await self._log(nd, info_embed(f"Server nickname **{'set to ' + nick if nick else 'cleared'}** by {ctx.author}", title="⚙️ Setting Changed"))
# ── Relay delay ────────────────────────────────────────────────────────
@ -343,13 +254,9 @@ class SettingsCommands(WormholeBase):
@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)."""
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]["relay_delay"] = max(0, seconds)
await ctx.send(embed=ok_embed(f"Relay delay set to {seconds}s for `{name}`."))
await self._log(nd, info_embed(f"Relay delay set to **{seconds}**s by {ctx.author}", title="⚙️ Setting Changed"))
# ── Ephemeral auto-delete ──────────────────────────────────────────────
@ -357,13 +264,9 @@ class SettingsCommands(WormholeBase):
@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)."""
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]["ephemeral_delay"] = max(0, seconds)
await ctx.send(embed=ok_embed(f"Ephemeral delay set to {seconds}s for `{name}`."))
await self._log(nd, info_embed(f"Ephemeral delay set to **{seconds}**s by {ctx.author}", title="⚙️ Setting Changed"))
# ── Discovery ──────────────────────────────────────────────────────────
@ -371,26 +274,18 @@ class SettingsCommands(WormholeBase):
@requires_role(Role.OWNER)
async def wh_set_public(self, ctx: commands.Context, name: str, enabled: bool) -> None:
"""Make network publicly discoverable."""
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]["public"] = enabled
await ctx.send(embed=ok_embed(f"`{name}` is now {'public' if enabled else 'private'}."))
await self._log(nd, info_embed(f"Network visibility set to **{'public' if enabled else 'private'}** by {ctx.author}", title="⚙️ Setting Changed"))
@WormholeBase.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)."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
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)}"))
await self._log(nd, info_embed(f"Tags updated: **{', '.join(tag_list)}** by {ctx.author}", title="⚙️ Setting Changed"))
# ── Channel overrides ──────────────────────────────────────────────────
@ -419,7 +314,6 @@ class SettingsCommands(WormholeBase):
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}`."))
await self._log(nd, info_embed(f"Channel override set: **{key} = {parsed}** by {ctx.author}", title="⚙️ Setting Changed"))
# ── View all settings ──────────────────────────────────────────────────

View file

@ -94,7 +94,7 @@ log = logging.getLogger("red.evecogs.wormhole")
_AUDIT_LIMIT = 500
class WormholeV4(
class Wormhole(
# Listeners
RelayListener,
SyncListener,
@ -137,8 +137,7 @@ class WormholeV4(
async def _init(self) -> None:
"""Deferred initialisation — called from setup() after cog is added."""
# Don't wait_until_ready() - causes 30s timeout during cog load
# Bot is already ready when cogs load on startup
await self.bot.wait_until_ready()
nets = await self.config.networks()
for name, nd in nets.items():
r = nd.get("rate_limit_rate", 5)
@ -159,7 +158,6 @@ class WormholeV4(
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._register_find_origin_context_menu()
self._ready.set()
log.info("Wormhole v%s ready — %d networks loaded", self.__version__, len(nets))
@ -171,28 +169,21 @@ class WormholeV4(
Note: slash commands are handled automatically by Red's hybrid_command
decorator on the mixin classes — do NOT manually register duplicates.
"""
# Remove any existing context menus first (idempotent registration)
menu_names = ("Report to Wormhole", "Delete from Network", "Wormhole Profile")
for name in menu_names:
try:
self.bot.tree.remove_command(name, type=discord.AppCommandType.message)
except Exception:
pass # Already removed or never existed
# Register 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)
async def cog_unload(self) -> None:
for task in self._bg_tasks:
task.cancel()
# Remove context menus (must specify type=discord.AppCommandType.message)
for cmd_name in ("Report to Wormhole", "Delete from Network", "Wormhole Profile", "Find Origin"):
self.bot.tree.remove_command(cmd_name, type=discord.AppCommandType.message)
for cmd_name in ("Report to Wormhole", "Bookmark (Wormhole)",
"Delete from Network", "Wormhole Profile"):
self.bot.tree.remove_command(cmd_name)
# ── Background task loops ──────────────────────────────────────────────
@ -657,6 +648,25 @@ class WormholeV4(
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:
@ -692,146 +702,30 @@ class WormholeV4(
async def _ctx_view_profile(self, interaction: discord.Interaction, user: discord.User) -> None:
nets = await self.config.networks()
global_banned = await self.config.global_banned_users()
profiles_found = []
total_messages = 0
for name, nd in nets.items():
p = nd.get("user_profiles", {}).get(str(user.id))
if not p:
continue
role = get_role(nd, user.id)
karma = nd.get("karma_scores", {}).get(str(user.id), 0)
is_banned = user.id in nd.get("banned_users", [])
is_muted = user.id in nd.get("muted_users", [])
profiles_found.append((name, p, role, karma, is_banned, is_muted))
total_messages += p.get("message_count", 0)
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
# Busiest networks first — most relevant at a glance
profiles_found.sort(key=lambda t: t[1].get("message_count", 0), reverse=True)
em = discord.Embed(title=f"🌀 Wormhole Profile — {user.display_name}", colour=COLOUR_INFO)
em = discord.Embed(
title=f"🌀 Wormhole Profile — {user.display_name}",
colour=COLOUR_INFO,
)
em.set_thumbnail(url=user.display_avatar.url)
em.add_field(name="User", value=f"{user}\n`{user.id}`", inline=True)
em.add_field(name="Account Created", value=discord.utils.format_dt(user.created_at, "R"), inline=True)
em.add_field(name="Networks Active In", value=str(len(profiles_found)), inline=True)
em.add_field(name="Total Messages (all networks)", value=f"{total_messages:,}", inline=True)
if user.id in global_banned:
em.add_field(name="⚠️ Global Status", value="**Banned from Wormhole network-wide**", inline=False)
for name, p, role, karma, is_banned, is_muted in profiles_found[:10]:
status_flags = []
if is_banned:
status_flags.append("🚫 Banned")
if is_muted:
status_flags.append("🔇 Muted")
status_str = " · ".join(status_flags) if status_flags else "✅ Active"
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"Karma: {karma}\n"
f"Servers seen in: {len(p.get('servers', []))}\n"
f"First seen: {p.get('first_seen', 'Unknown')[:10]}\n"
f"Status: {status_str}"
f"First seen: {p.get('first_seen', 'Unknown')[:10]}"
),
inline=True,
)
if len(profiles_found) > 10:
em.set_footer(text=f"Showing 10 of {len(profiles_found)} networks (busiest first)")
await interaction.response.send_message(embed=em, ephemeral=True)
async def _ctx_find_origin(self, interaction: discord.Interaction, message: discord.Message) -> None:
"""Context menu: resolve a relayed copy back to its true origin server/channel/author.
The relay map only tracks relayed-copy id -> original id (there's no
direct index of "which channel is the origin for this message"), so
when the right-clicked message isn't itself the original, this falls
back to searching the network's member channels — the same technique
`_ctx_delete_message`/`wh mod nuke` already use for the same reason.
"""
net_name = await self._net_for_ch(message.channel.id)
if not net_name:
await interaction.response.send_message(
"This channel isn't part of a wormhole 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
orig_id = self.msg_map.get_original(net_name, message.id) or message.id
if orig_id == message.id:
origin_message = message
else:
origin_message = None
for ch_id in nd.get("channels", []):
ch = self.bot.get_channel(ch_id)
if not ch:
continue
try:
origin_message = await ch.fetch_message(orig_id)
break
except (discord.NotFound, discord.Forbidden, discord.HTTPException):
continue
if not origin_message:
await interaction.response.send_message(
"Couldn't locate the original message — it may have been deleted, "
"or predates the bot's current in-memory relay map.",
ephemeral=True,
)
return
# If what we resolved still looks like a relayed copy (bot/webhook authored)
# rather than a genuine human original, the map couldn't fully resolve it —
# say so plainly instead of confidently showing the wrong server as "the origin".
looks_relayed = origin_message.webhook_id is not None or origin_message.author.id == self.bot.user.id
if looks_relayed and orig_id == message.id:
await interaction.response.send_message(
"This looks like a relayed copy, but its true origin isn't in the "
"bot's in-memory relay map (it may predate the bot's current session).",
ephemeral=True,
)
return
guild = origin_message.guild
channel = origin_message.channel
author = origin_message.author
embed = discord.Embed(title="🔎 Wormhole — Origin Found", colour=COLOUR_INFO)
embed.add_field(name="Server", value=f"{guild.name}\n`{guild.id}`" if guild else "Unknown", inline=True)
embed.add_field(
name="Channel",
value=channel.mention if hasattr(channel, "mention") else str(channel),
inline=True,
)
embed.add_field(name="Author", value=f"{author}\n`{author.id}`", inline=True)
embed.add_field(name="Message ID", value=f"`{origin_message.id}`", inline=True)
embed.add_field(name="Jump to Message", value=f"[Click here]({origin_message.jump_url})", inline=True)
if author.display_avatar:
embed.set_thumbnail(url=author.display_avatar.url)
await interaction.response.send_message(embed=embed, ephemeral=True)
await self._audit(net_name, "ctx_find_origin", str(interaction.user), str(orig_id))
def _register_find_origin_context_menu(self) -> None:
"""Register the "Find Origin" context menu independently of the bulk
Report/Bookmark/Delete/Profile registration above, which is currently
disabled — leaving that decision alone rather than reviving it as a
side effect of adding this one."""
try:
self.bot.tree.remove_command("Find Origin", type=discord.AppCommandType.message)
except Exception:
pass
ctx_find_origin = app_commands.ContextMenu(name="Find Origin", callback=self._ctx_find_origin)
self.bot.tree.add_command(ctx_find_origin)
# ── Root command group is defined in commands/_base.py ────────────────

View file

@ -266,7 +266,7 @@ class RelayListener:
except Exception as exc:
log.debug("Auto-response error (relay continues): %s", exc)
# ── Relay ─────────────────────────────────────────────────────────
# ── Relay ──────────────────────────────────────────────────────────
if _tracing:
trace.append("✅ All gate checks passed — calling _do_relay")
try:
@ -385,9 +385,6 @@ class RelayListener:
if bd and not bd.get("frozen"):
relay_targets.extend(bd.get("channels", []))
# ── Disallow all mentions in relay ──────────────────────────────────
no_mentions = discord.AllowedMentions.none()
# ── Send to each target channel ────────────────────────────────────
mapping: Dict[int, int] = {}
for ch_id in relay_targets:
@ -417,12 +414,7 @@ class RelayListener:
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,
allowed_mentions=no_mentions,
**_view_kw,
)
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
@ -438,7 +430,6 @@ class RelayListener:
content=compact_format(g, display, content),
files=files or None,
embeds=ce[:10] or None,
allowed_mentions=no_mentions,
**_view_kw,
)
@ -481,8 +472,6 @@ class RelayListener:
if not send_content and not files and not all_embeds:
send_content = "*[empty message]*"
no_mentions = discord.AllowedMentions.none()
try:
wh = await self._wh(ch)
return await wh.send(
@ -492,7 +481,6 @@ class RelayListener:
files=files or discord.utils.MISSING,
embeds=all_embeds[:10] or discord.utils.MISSING,
wait=True,
allowed_mentions=no_mentions,
**view_kw,
)
except (discord.NotFound, discord.InvalidData):
@ -518,22 +506,13 @@ class RelayListener:
files=files2 or discord.utils.MISSING,
embeds=all_embeds2[:10] or discord.utils.MISSING,
wait=True,
allowed_mentions=no_mentions,
**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],
allowed_mentions=no_mentions,
**view_kw,
)
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],
allowed_mentions=no_mentions,
**view_kw,
)
return await ch.send(embeds=[em] + (extra_embeds or [])[:9], **view_kw)

View file

@ -13,10 +13,10 @@ DEFAULT_NETWORK: dict = {
"staff": {}, # {user_id_str: Role int} (new hierarchy)
"staff_ids": [], # LEGACY — flat list, migrated on read
# ── Channels ──────────────────────────────────────────────────────────[...]
# ── Channels ───────────────────────────────────────────────────────────
"channels": [],
# ── Identity ──────────────────────────────────────────────────────────[...]
# ── Identity ───────────────────────────────────────────────────────────
"use_webhooks": True,
"relay_mode": "webhook", # webhook | embed | compact
"image_mode": "user", # user | server | custom
@ -52,14 +52,13 @@ DEFAULT_NETWORK: dict = {
"rate_limit_rate": 5,
"rate_limit_per": 10.0,
# ── Logging ──────────────────────────────────────────────────────────[...]
# ── Logging ────────────────────────────────────────────────────────────
"log_channel": None,
"report_channel": None,
# ── Server nicknames ───────────────────────────────────────────────────
"server_nicknames": {},
# ── Stats ───────────────────────────────────────────────────────────[...]
# ── Stats ──────────────────────────────────────────────────────────────
"total_messages": 0,
"created_at": None,
@ -86,7 +85,7 @@ DEFAULT_NETWORK: dict = {
"raid_threshold": 10,
},
# ── Invites ──────────────────────────────────────────────────────────[...]
# ── Invites ────────────────────────────────────────────────────────────
"invites": {},
"vanity_invite": None,
@ -105,29 +104,28 @@ DEFAULT_NETWORK: dict = {
"user_profiles": {},
"blackout_schedules": [],
# ── DM relay ─────────────────────────────────────────────────────────[...]
# ── DM relay ───────────────────────────────────────────────────────────
"dm_enabled": False,
"dm_subscribers": [],
"dm_relay_mode": "embed", # embed | compact | plain
# ── Starboard ─────────────────────────────────────────────────────────[...]
# ── Starboard ──────────────────────────────────────────────────────────
"starboard_enabled": False,
"starboard_channel": None,
"starboard_threshold": 3,
"starred_messages": {},
# ── Audit log ─────────────────────────────────────────────────────────[...]
# ── Audit log ──────────────────────────────────────────────────────────
"audit_log": [],
# ── Attachment filters ─────────────────────────────────────────────────
"blocked_extensions": [],
"max_filesize": None,
# ── Karma ──────────────────────────────────────────────────────────[...]
# ── Karma ──────────────────────────────────────────────────────────────
"karma_enabled": False,
"karma_emoji": "👍",
"karma_scores": {},
"karma_penalties": {},
# ── MOTD / Rules ───────────────────────────────────────────────────────
"motd": "",
@ -136,7 +134,7 @@ DEFAULT_NETWORK: dict = {
# ── Relay delay ────────────────────────────────────────────────────────
"relay_delay": 0,
# ── Highlights ─────────────────────────────────────────────────--------
# ── Highlights ─────────────────────────────────────────────────────────
"highlights": {},
# ── Network roles (legacy, unused) ─────────────────────────────────────
@ -148,10 +146,10 @@ DEFAULT_NETWORK: dict = {
# ── Scheduled messages ─────────────────────────────────────────────────
"scheduled_messages": [],
# ── Slowmode ─────────────────────────────────────────────────────────[...]
# ── Slowmode ───────────────────────────────────────────────────────────
"slowmode": 0,
# ── Discovery ─────────────────────────────────────────────────────────[...]
# ── Discovery ──────────────────────────────────────────────────────────
"public": False,
"tags": [],
@ -174,20 +172,20 @@ DEFAULT_NETWORK: dict = {
# ── Media-only mode ────────────────────────────────────────────────────
"media_only": False,
# ── Analytics ─────────────────────────────────────────────────────────[...]
# ── Analytics ──────────────────────────────────────────────────────────
"analytics": {
"hourly": {},
"top_users": {},
},
# ── Health ──────────────────────────────────────────────────────────[...]
# ── Health ─────────────────────────────────────────────────────────────
"last_health_check": None,
"unhealthy_channels": [],
# ── Polls ──────────────────────────────────────────────────────────[...]
# ── Polls ──────────────────────────────────────────────────────────────
"active_polls": {},
# ── AFK system ─────────────────────────────────────────────────--------
# ── AFK system ─────────────────────────────────────────────────────────
"afk_users": {},
# ── Personal ignore list ───────────────────────────────────────────────
@ -222,14 +220,6 @@ DEFAULT_NETWORK: dict = {
# ── Report system ──────────────────────────────────────────────────────
"reports": [],
"report_counter": 0,
# ── Action presets & history ───────────────────────────────────────────
"action_presets": {
"warn": {"karma_delta": -1, "retain_days": 90, "dm_message": "You have received a warning from staff."},
"timeout": {"seconds": 60 * 60, "karma_delta": -2, "retain_days": 90},
"ban": {"days": 0, "karma_delta": -10, "retain_days": 90},
},
"action_history": {},
}

View file

@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
import discord
from ..utils import ok_embed, truncate, warn_embed
from ..utils import ok_embed, warn_embed
if TYPE_CHECKING:
from ..core import Wormhole
@ -29,33 +29,30 @@ class ReportModal(discord.ui.Modal, title="Report Message"):
self.message = message
async def on_submit(self, interaction: discord.Interaction) -> None:
# report_id must be computed and written inside the same locked
# section — reading report_counter beforehand let two concurrent
# reports land on the same id and left the counter not advanced.
async with self.cog.config.networks() as ns:
nd = ns.get(self.net_name)
if not nd:
await interaction.response.send_message("Network not found.", ephemeral=True)
return
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,
"message_id": self.message.id,
"channel_id": self.message.channel.id,
"guild_id": self.message.guild.id if self.message.guild else None,
"content_preview": truncate(self.message.content or "*[no text]*", 300),
"jump_url": self.message.jump_url,
"reason": self.reason.value,
"timestamp": datetime.now(timezone.utc).isoformat(),
"resolved": False,
"resolved_by": None,
"resolution": None,
}
nd.setdefault("reports", []).append(report)
nd["report_counter"] = report_id
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:
@ -72,13 +69,6 @@ class ReportModal(discord.ui.Modal, title="Report Message"):
except discord.Forbidden:
pass
# Notify configured report channel with action buttons
try:
nd_full = await self.cog._net(self.net_name)
await self.cog._notify_report_channel(nd_full, self.net_name, report)
except Exception:
pass
await interaction.response.send_message(
embed=ok_embed(f"Report #{report_id} submitted. Staff will review it."),
ephemeral=True,

View file

@ -3,7 +3,6 @@
from __future__ import annotations
import discord
from typing import Optional
def reply_jump_view(url: str) -> discord.ui.View:
@ -15,44 +14,3 @@ def reply_jump_view(url: str) -> discord.ui.View:
url=url,
))
return view
class _ResolveButton(discord.ui.Button):
def __init__(self, cog, net_name: str, report_id: int):
super().__init__(style=discord.ButtonStyle.primary, label="Resolve")
self.cog = cog
self.net_name = net_name
self.report_id = report_id
async def callback(self, interaction: discord.Interaction) -> None: # type: ignore[override]
await self.cog._report_resolve_via_interaction(interaction, self.net_name, self.report_id)
class _DismissButton(discord.ui.Button):
def __init__(self, cog, net_name: str, report_id: int):
super().__init__(style=discord.ButtonStyle.secondary, label="Dismiss")
self.cog = cog
self.net_name = net_name
self.report_id = report_id
async def callback(self, interaction: discord.Interaction) -> None: # type: ignore[override]
await self.cog._report_dismiss_via_interaction(interaction, self.net_name, self.report_id)
class ReportActionView(discord.ui.View):
"""View attached to report notifications with action buttons.
The view delegates actual action handling back to the Wormhole cog so the
existing permission and state logic is reused.
"""
def __init__(self, cog, net_name: str, report_id: int, jump_url: Optional[str] = None, *, timeout: Optional[float] = None):
super().__init__(timeout=timeout)
self.cog = cog
self.net_name = net_name
self.report_id = report_id
if jump_url:
self.add_item(discord.ui.Button(style=discord.ButtonStyle.link, label="Jump to message", url=jump_url))
# Action buttons
self.add_item(_ResolveButton(cog, net_name, report_id))
self.add_item(_DismissButton(cog, net_name, report_id))

View file

@ -214,14 +214,13 @@ def generate_invite_code(length: int = 8) -> str:
def sanitise_mentions(content: str, config: dict) -> str:
"""Legacy mention control — kept for backwards compatibility."""
original = content
if config.get("strip_everyone"):
content = content.replace("@everyone", "").replace("@here", "")
if config.get("strip_role_mentions"):
content = re.sub(r"<@&\d+>\s?", "", content)
if config.get("strip_user_mentions"):
content = re.sub(r"<@!?\d+>\s?", "", content)
if content != original:
if content != content:
content = re.sub(r" +", " ", content).strip()
return content

4782
wormhole/wormhole.py Normal file

File diff suppressed because it is too large Load diff