Compare commits
1 commit
main
...
docs/add-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2df0d504d0 |
60 changed files with 5809 additions and 8815 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 4.8 MiB |
113
README.md
113
README.md
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
@ -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."))
|
||||
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
from .antidoxxing import AntiDoxxing
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(AntiDoxxing(bot))
|
||||
|
|
@ -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
53
dashboard/README.md
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
@ -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`.")
|
||||
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
||||
|
|
@ -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> · GhostWipe v{html_lib.escape(version)} · {html_lib.escape(generated_at or event_time_str)}</div>
|
||||
</div>
|
||||
<script>{SEARCH_SCRIPT}</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return doc
|
||||
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
@ -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,
|
||||
))
|
||||
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
||||
|
|
@ -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
59
newhelpmenu/README.md
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
93
nexuscore/README.md
Normal 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
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
74
selfbotguard/README.md
Normal 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
84
serverwhitelist/README.md
Normal 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
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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!
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
84
userslash/README.md
Normal 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
|
||||
|
|
@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
95
voicemeister/README.md
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
154
wormhole/README.md
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
176
wormhole/core.py
176
wormhole/core.py
|
|
@ -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 ────────────────
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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": {},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
4782
wormhole/wormhole.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue