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:
parent
0e26fe311f
commit
039e85d65a
2 changed files with 366 additions and 282 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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__ · __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)} · {html_lib.escape(generated_at or event_time_str)}</div>
|
||||
</div>
|
||||
<script>{SEARCH_SCRIPT}</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return doc
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue