Add GhostWipe cog: auto-purge departing member's messages + HTML audit log

Deletes a member's messages server-wide the moment they leave, are kicked,
or are banned (all fire on_member_remove; audit log classifies which).
Generates a self-contained HTML report with channel-switching, search, and
attachment previews, alongside per-guild config for triggers, exemptions,
scan scope, dry-run mode, and report retention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rosie Val 2026-07-28 17:52:18 +01:00
parent 6cc7f8e494
commit 309d0e3ea7
6 changed files with 1448 additions and 1 deletions

View file

@ -136,6 +136,48 @@ 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 |
---
## Architecture
```

22
ghostwipe/__init__.py Normal file
View file

@ -0,0 +1,22 @@
"""
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))

885
ghostwipe/ghostwipe.py Normal file
View file

@ -0,0 +1,885 @@
"""
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 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")
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 _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"]:
for ch in guild.text_channels:
if ch.id in ignored:
continue
try:
async for th in ch.archived_threads(limit=100):
if th.id in ignored:
continue
targets.append(("thread", th, th.name))
except (discord.Forbidden, discord.HTTPException):
continue
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": [],
}
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:
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)
if rate_delay:
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}"
event_time = datetime.now(timezone.utc)
html = generate_report_html(
guild_name=guild.name,
member_name=member_display,
member_id=member.id,
avatar_url=member_avatar_url,
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,
)
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_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]],
) -> 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"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
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."""
if ctx.invoked_subcommand is None:
await ctx.send_help(ctx.command)
@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."""
if ctx.invoked_subcommand is None:
await ctx.send_help(ctx.command)
@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."""
if ctx.invoked_subcommand is None:
await ctx.send_help(ctx.command)
@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."""
if ctx.invoked_subcommand is None:
await ctx.send_help(ctx.command)
@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."""
if ctx.invoked_subcommand is None:
await ctx.send_help(ctx.command)
@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"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`.")

24
ghostwipe/info.json Normal file
View file

@ -0,0 +1,24 @@
{
"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": []
}

474
ghostwipe/report.py Normal file
View file

@ -0,0 +1,474 @@
"""
GhostWipe HTML report generator.
Builds a single self-contained HTML file (no external resources) that lets a
moderator browse every message GhostWipe deleted for a departing member,
switching between channels, searching content, and inspecting attachments.
"""
from __future__ import annotations
import html
import json
from typing import Any, Dict, List
TEMPLATE = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__PAGE_TITLE__</title>
<style>
: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;
--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; height: 100%;
background: var(--bg); color: var(--text);
font-family: "gg sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 14px;
overflow: hidden;
}
#app { display: flex; flex-direction: column; 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;
}
#header img.avatar {
width: 48px; height: 48px; border-radius: 50%; flex-shrink: 0;
border: 2px solid var(--border); background: var(--panel-alt);
}
#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-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; }
#channel-list li {
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;
}
#channel-list li:hover { background: var(--panel-alt); color: var(--text); }
#channel-list li.active { background: var(--accent-soft); color: var(--text); }
#channel-list li .ch-name { display: flex; align-items: center; gap: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#channel-list li .ch-icon { opacity: .6; flex-shrink: 0; }
#channel-list li .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;
}
#channel-list li.active .ch-count { background: var(--accent); color: #fff; }
#channel-list li.skipped { opacity: .5; }
#channel-list li .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); }
/* Main pane */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
#main-header {
padding: 12px 20px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 10px; flex-shrink: 0;
}
#main-header h2 { margin: 0; font-size: 15px; font-weight: 700; }
#main-header .count-pill {
background: var(--panel-alt); border: 1px solid var(--border); border-radius: 999px;
padding: 2px 10px; font-size: 12px; color: var(--text-dim);
}
#messages { 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 .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; }
.msg .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;
}
::-webkit-scrollbar { width: 10px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 6px; }
::-webkit-scrollbar-track { background: transparent; }
@media (max-width: 720px) {
#sidebar { width: 200px; }
#header .stats { gap: 12px; }
}
</style>
</head>
<body>
<div id="app">
<div id="header">
<img class="avatar" src="__AVATAR_URL__" alt="avatar" onerror="this.style.visibility='hidden'">
<div class="id-block">
<div class="username">__USERNAME__ <span class="badge __REASON_BADGE_CLASS__">__REASON_LABEL__</span><span id="dryrun-badge"></span></div>
<div class="userid">ID: __USER_ID__</div>
</div>
<div class="stats">
<div class="stat"><div class="num" id="stat-total">0</div><div class="lbl">Deleted</div></div>
<div class="stat"><div class="num" id="stat-channels">0</div><div class="lbl">Channels</div></div>
<div class="stat"><div class="num" id="stat-skipped">0</div><div class="lbl">Skipped</div></div>
</div>
<div class="meta-row" id="meta-row"></div>
</div>
<div id="body">
<div id="sidebar">
<div class="sidebar-title">Channels</div>
<ul id="channel-list"></ul>
<div id="search-wrap">
<input id="search" type="text" placeholder="Search message content…">
</div>
</div>
<div id="main">
<div id="main-header">
<h2 id="channel-title">Select a channel</h2>
<span class="count-pill" id="channel-count-pill"></span>
</div>
<div id="messages"></div>
</div>
</div>
<div id="footer">Generated by GhostWipe __GHOSTWIPE_VERSION__ &middot; __GENERATED_AT__</div>
</div>
<script id="report-data" type="application/json">__REPORT_DATA_JSON__</script>
<script>
(function () {
const data = JSON.parse(document.getElementById('report-data').textContent);
const channelListEl = document.getElementById('channel-list');
const messagesEl = document.getElementById('messages');
const channelTitleEl = document.getElementById('channel-title');
const channelCountPillEl = document.getElementById('channel-count-pill');
const searchEl = document.getElementById('search');
let activeChannelId = null;
let searchTerm = '';
document.getElementById('stat-total').textContent = data.totals.messages_deleted;
document.getElementById('stat-channels').textContent = data.totals.channels_affected;
document.getElementById('stat-skipped').textContent = data.totals.channels_skipped;
if (data.dry_run) {
document.getElementById('dryrun-badge').innerHTML = '<span class="badge badge-dryrun">Dry Run</span>';
}
document.getElementById('meta-row').textContent = data.meta_line || '';
const icons = { text: '#', thread: '🩹', voice: '🔊' };
function renderSidebar() {
channelListEl.innerHTML = '';
const sorted = [...data.channels].sort((a, b) => b.message_count - a.message_count);
for (const ch of sorted) {
const li = document.createElement('li');
li.dataset.id = ch.id;
if (ch.skipped) li.classList.add('skipped');
if (ch.id === activeChannelId) li.classList.add('active');
const nameSpan = document.createElement('span');
nameSpan.className = 'ch-name';
nameSpan.innerHTML = '<span class="ch-icon">' + (icons[ch.type] || '#') + '</span>' + escapeHtml(ch.name);
li.appendChild(nameSpan);
if (ch.skipped) {
const tag = document.createElement('span');
tag.className = 'skip-tag';
tag.textContent = 'skipped';
li.appendChild(tag);
} else {
const count = document.createElement('span');
count.className = 'ch-count';
count.textContent = ch.message_count;
li.appendChild(count);
}
li.addEventListener('click', () => selectChannel(ch.id));
channelListEl.appendChild(li);
}
}
function escapeHtml(str) {
const d = document.createElement('div');
d.textContent = str == null ? '' : String(str);
return d.innerHTML;
}
function selectChannel(id) {
activeChannelId = id;
renderSidebar();
renderMessages();
}
function renderMessages() {
const ch = data.channels.find(c => c.id === activeChannelId);
messagesEl.innerHTML = '';
if (!ch) {
channelTitleEl.textContent = 'Select a channel';
channelCountPillEl.textContent = '';
return;
}
channelTitleEl.textContent = (ch.type === 'thread' ? '🩹 ' : '# ') + ch.name;
channelCountPillEl.textContent = ch.message_count + ' message' + (ch.message_count === 1 ? '' : 's');
if (ch.skipped) {
const banner = document.createElement('div');
banner.className = 'skip-banner';
banner.textContent = 'This channel was skipped: ' + (ch.skip_reason || 'unknown reason');
messagesEl.appendChild(banner);
return;
}
let msgs = ch.messages || [];
if (searchTerm) {
const term = searchTerm.toLowerCase();
msgs = msgs.filter(m => (m.content || '').toLowerCase().includes(term));
}
if (!msgs.length) {
const empty = document.createElement('div');
empty.className = 'empty-state';
empty.textContent = searchTerm ? 'No messages match your search.' : 'No messages recorded for this channel.';
messagesEl.appendChild(empty);
return;
}
for (const m of msgs) {
const card = document.createElement('div');
card.className = 'msg';
const top = document.createElement('div');
top.className = 'msg-top';
top.innerHTML = '<span>' + escapeHtml(m.timestamp) + '</span><span>#' + escapeHtml(m.id) + '</span>';
card.appendChild(top);
const content = document.createElement('div');
if (m.redacted) {
content.className = 'msg-content redacted';
content.textContent = '[content redacted by server configuration]';
} else {
content.className = 'msg-content';
content.textContent = m.content || (m.attachments && m.attachments.length ? '' : '[no text content]');
}
card.appendChild(content);
if (!m.redacted && m.attachments && m.attachments.length) {
const wrap = document.createElement('div');
wrap.className = 'attachments';
for (const a of m.attachments) {
if (a.is_image) {
const img = document.createElement('img');
img.className = 'attachment-img';
img.src = a.url;
img.alt = a.filename;
img.loading = 'lazy';
img.onerror = function () { this.style.display = 'none'; };
wrap.appendChild(img);
} else {
const a_el = document.createElement('a');
a_el.className = 'attachment-chip';
a_el.href = a.url;
a_el.target = '_blank';
a_el.rel = 'noopener noreferrer';
a_el.textContent = '📎 ' + a.filename + (a.size ? ' (' + a.size + ')' : '');
wrap.appendChild(a_el);
}
}
card.appendChild(wrap);
}
if (!m.redacted && m.stickers && m.stickers.length) {
for (const s of m.stickers) {
const chip = document.createElement('span');
chip.className = 'sticker-chip';
chip.textContent = '✨ ' + s;
card.appendChild(chip);
}
}
messagesEl.appendChild(card);
}
}
searchEl.addEventListener('input', () => {
searchTerm = searchEl.value;
renderMessages();
});
renderSidebar();
const first = [...data.channels].sort((a, b) => b.message_count - a.message_count)[0];
if (first) selectChannel(first.id);
})();
</script>
</body>
</html>
"""
def _badge_class(reason: str) -> str:
return {
"left": "badge-left",
"kicked": "badge-kicked",
"banned": "badge-banned",
}.get(reason, "badge-left")
def _badge_label(reason: str) -> str:
return {
"left": "Left",
"kicked": "Kicked",
"banned": "Banned",
}.get(reason, reason.title())
def generate_report_html(
*,
guild_name: str,
member_name: str,
member_id: int,
avatar_url: str,
reason: str,
event_time_str: str,
moderator: str = None,
mod_reason: str = None,
dry_run: bool = False,
reveal_content: bool = True,
channels: List[Dict[str, Any]],
version: str = "1.0.0",
generated_at: str = "",
) -> str:
"""Build the standalone HTML report string.
``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"))
if not reveal_content:
for ch in channels:
for m in ch.get("messages", []):
m["redacted"] = True
meta_bits = [f"Server: {guild_name}"]
if moderator:
meta_bits.append(f"By: {moderator}")
if mod_reason:
meta_bits.append(f"Reason: {mod_reason}")
meta_line = " • ".join(meta_bits)
payload = {
"guild_name": guild_name,
"member_name": member_name,
"member_id": str(member_id),
"reason": reason,
"dry_run": dry_run,
"meta_line": meta_line,
"totals": {
"messages_deleted": total_deleted,
"channels_affected": channels_affected,
"channels_skipped": channels_skipped,
},
"channels": channels,
}
out = TEMPLATE
out = out.replace("__PAGE_TITLE__", html.escape(f"GhostWipe Report — {member_name}"))
out = out.replace("__AVATAR_URL__", html.escape(avatar_url or "", quote=True))
out = out.replace("__USERNAME__", html.escape(member_name))
out = out.replace("__REASON_BADGE_CLASS__", _badge_class(reason))
out = out.replace("__REASON_LABEL__", _badge_label(reason))
out = out.replace("__USER_ID__", str(member_id))
out = out.replace("__GHOSTWIPE_VERSION__", html.escape(version))
out = out.replace("__GENERATED_AT__", html.escape(generated_at or event_time_str))
out = out.replace("__REPORT_DATA_JSON__", json.dumps(payload))
return out

View file

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