Fix GhostWipe report: render server-side, embed avatar, cut purge latency

The HTML report was entirely JavaScript-driven — stats/sidebar/messages only
populated from an embedded JSON blob after load. Viewers that don't execute
scripts (or block network access for local files) showed a blank 0/0/0
skeleton even though the purge worked correctly. Now every stat, the channel
list, and every message are rendered directly into the HTML by Python; JS is
only a progressive enhancement for search. Avatar is downloaded and embedded
as a base64 data URI instead of a live CDN link, so it always renders. Also
parallelized archived-thread lookups, skip the rate-limit pause on channels
with nothing to delete, and post an immediate placeholder log message that
gets edited with the final results instead of the channel sitting silent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rosie Val 2026-07-28 18:32:30 +01:00
parent 0e26fe311f
commit 039e85d65a
2 changed files with 366 additions and 282 deletions

View file

@ -10,6 +10,7 @@ classified via the audit log purely for reporting/toggle purposes.
from __future__ import annotations
import asyncio
import base64
import json
import logging
import time
@ -47,6 +48,8 @@ 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:
@ -176,6 +179,39 @@ class GhostWipe(commands.Cog):
"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]]:
@ -200,16 +236,19 @@ class GhostWipe(commands.Cog):
targets.append(("thread", th, th.name))
if conf["include_archived_threads"]:
for ch in guild.text_channels:
if ch.id in ignored:
continue
async def _archived_for(ch: discord.TextChannel) -> List[discord.Thread]:
try:
async for th in ch.archived_threads(limit=100):
if th.id in ignored:
continue
targets.append(("thread", th, th.name))
return [th async for th in ch.archived_threads(limit=100)]
except (discord.Forbidden, discord.HTTPException):
continue
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
@ -292,6 +331,15 @@ class GhostWipe(commands.Cog):
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"]
@ -303,7 +351,11 @@ class GhostWipe(commands.Cog):
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:
# 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
@ -312,13 +364,13 @@ class GhostWipe(commands.Cog):
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)
avatar_data_uri = await avatar_task
html = generate_report_html(
guild_name=guild.name,
member_name=member_display,
member_id=member.id,
avatar_url=member_avatar_url,
avatar_data_uri=avatar_data_uri,
reason=reason,
event_time_str=event_time.strftime("%Y-%m-%d %H:%M:%S UTC"),
moderator=moderator,
@ -388,7 +440,7 @@ class GhostWipe(commands.Cog):
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,
dry_run, html_path, channels_data, placeholder_message,
)
def _prune_old_reports(self, guild_id: int, keep_days: int) -> None:
@ -403,6 +455,46 @@ class GhostWipe(commands.Cog):
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,
@ -419,6 +511,7 @@ class GhostWipe(commands.Cog):
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:
@ -464,6 +557,19 @@ class GhostWipe(commands.Cog):
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)

View file

@ -1,24 +1,24 @@
"""
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.
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
import json
from typing import Any, Dict, List
import html as html_lib
from typing import Any, Dict, List, Optional
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>
STYLE = r"""
:root {
--bg: #0e0f13;
--bg-alt: #15171d;
@ -38,17 +38,9 @@ TEMPLATE = r"""<!doctype html>
}
@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);
--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"] {
@ -63,13 +55,12 @@ TEMPLATE = r"""<!doctype html>
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; height: 100%;
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;
overflow: hidden;
}
#app { display: flex; flex-direction: column; height: 100vh; }
#app { display: flex; flex-direction: column; min-height: 100vh; }
/* Header */
#header {
@ -78,10 +69,14 @@ html, body {
border-bottom: 1px solid var(--border); flex-shrink: 0;
flex-wrap: wrap;
}
#header img.avatar {
.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; }
@ -92,6 +87,7 @@ html, body {
.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; }
@ -113,22 +109,20 @@ html, body {
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 {
.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;
}
#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 {
.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;
}
#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); }
.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 {
@ -136,27 +130,30 @@ html, body {
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; }
#main-header {
.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;
}
#main-header h2 { margin: 0; font-size: 15px; font-weight: 700; }
#main-header .count-pill {
.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);
}
#messages { flex: 1; overflow-y: auto; padding: 16px 20px; }
.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; }
.msg .attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.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;
@ -181,204 +178,24 @@ html, body {
#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>
SEARCH_SCRIPT = r"""
(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);
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);
}
}
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>
"""
@ -387,6 +204,7 @@ def _badge_class(reason: str) -> str:
"left": "badge-left",
"kicked": "badge-kicked",
"banned": "badge-banned",
"manual": "badge-manual",
}.get(reason, "badge-left")
@ -395,28 +213,125 @@ def _badge_label(reason: str) -> str:
"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,
avatar_url: str,
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,
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:
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,
@ -433,42 +348,105 @@ def generate_report_html(
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
# 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: {guild_name}"]
meta_bits = [f"Server: {html_lib.escape(guild_name)}"]
if moderator:
meta_bits.append(f"By: {moderator}")
meta_bits.append(f"By: {html_lib.escape(moderator)}")
if mod_reason:
meta_bits.append(f"Reason: {mod_reason}")
meta_bits.append(f"Reason: {html_lib.escape(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,
}
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>'
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
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"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">Generated by GhostWipe {html_lib.escape(version)} &middot; {html_lib.escape(generated_at or event_time_str)}</div>
</div>
<script>{SEARCH_SCRIPT}</script>
</body>
</html>
"""
return doc