""" GhostWipe HTML report generator. Builds a single self-contained HTML file (no external resources, no required JavaScript) that lets a moderator browse every message GhostWipe deleted for a departing member. All content — stats, avatar, channel list, and every message — is rendered server-side directly into the HTML/CSS, using a pure CSS radio/label technique for channel switching. This way the report displays correctly even in minimal viewers that don't execute scripts or load remote network resources (a common failure mode for local file:// HTML viewers). A small script is layered on top purely as a progressive enhancement for live search filtering; if it never runs, everything is still fully visible via the CSS tab switcher. """ from __future__ import annotations import html as html_lib from typing import Any, Dict, List, Optional STYLE = r""" :root { --bg: #0e0f13; --bg-alt: #15171d; --panel: #1a1d24; --panel-alt: #20232b; --border: #2a2e38; --text: #e7e9ee; --text-dim: #9096a3; --text-faint: #5b6270; --accent: #8b5cf6; --accent-soft: rgba(139, 92, 246, 0.15); --danger: #f04747; --warn: #faa61a; --ok: #3ba55d; --ruby: #e0115f; --radius: 10px; --shadow: 0 8px 24px rgba(0,0,0,0.35); } @media (prefers-color-scheme: light) { :root { --bg: #f4f5f8; --bg-alt: #ffffff; --panel: #ffffff; --panel-alt: #f0f1f5; --border: #e1e3e9; --text: #1a1d24; --text-dim: #565c6a; --text-faint: #8b909c; --accent: #7c3aed; --accent-soft: rgba(124, 58, 237, 0.10); --shadow: 0 8px 24px rgba(0,0,0,0.08); } } :root[data-theme="dark"] { --bg: #0e0f13; --bg-alt: #15171d; --panel: #1a1d24; --panel-alt: #20232b; --border: #2a2e38; --text: #e7e9ee; --text-dim: #9096a3; --text-faint: #5b6270; --accent: #8b5cf6; --accent-soft: rgba(139, 92, 246, 0.15); --shadow: 0 8px 24px rgba(0,0,0,0.35); } :root[data-theme="light"] { --bg: #f4f5f8; --bg-alt: #ffffff; --panel: #ffffff; --panel-alt: #f0f1f5; --border: #e1e3e9; --text: #1a1d24; --text-dim: #565c6a; --text-faint: #8b909c; --accent: #7c3aed; --accent-soft: rgba(124, 58, 237, 0.10); --shadow: 0 8px 24px rgba(0,0,0,0.08); } * { box-sizing: border-box; } html, body { margin: 0; padding: 0; min-height: 100%; background: var(--bg); color: var(--text); font-family: "gg sans", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 14px; } #app { display: flex; flex-direction: column; min-height: 100vh; } /* Header */ #header { display: flex; align-items: center; gap: 16px; padding: 14px 20px; background: var(--bg-alt); border-bottom: 1px solid var(--border); flex-shrink: 0; flex-wrap: wrap; } .avatar, .avatar-fallback { width: 48px; height: 48px; border-radius: 50%; flex-shrink: 0; border: 2px solid var(--border); background: var(--panel-alt); } .avatar-fallback { display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 18px; color: var(--accent); } #header .id-block { min-width: 180px; } #header .username { font-weight: 700; font-size: 16px; } #header .userid { color: var(--text-faint); font-size: 12px; } .badge { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 700; letter-spacing: .03em; text-transform: uppercase; } .badge-left { background: rgba(120,120,130,0.18); color: #b7bcc7; } .badge-kicked { background: rgba(250,166,26,0.16); color: var(--warn); } .badge-banned { background: rgba(240,71,71,0.16); color: var(--danger); } .badge-manual { background: var(--accent-soft); color: var(--accent); } .badge-dryrun { background: var(--accent-soft); color: var(--accent); margin-left: 6px; } #header .stats { display: flex; gap: 22px; margin-left: auto; flex-wrap: wrap; } #header .stat { text-align: center; } #header .stat .num { font-size: 20px; font-weight: 700; } #header .stat .lbl { font-size: 10px; color: var(--text-faint); text-transform: uppercase; letter-spacing: .05em; } #header .meta-row { width: 100%; font-size: 12px; color: var(--text-dim); margin-top: 2px; } /* Body layout */ #body { flex: 1; display: flex; min-height: 0; } #sidebar { width: 280px; flex-shrink: 0; background: var(--bg-alt); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; } #sidebar .sidebar-title { padding: 12px 16px 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); font-weight: 700; } #channel-list { list-style: none; margin: 0; padding: 4px 8px; overflow-y: auto; flex: 1; } .ch-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 9px 10px; border-radius: 8px; cursor: pointer; margin-bottom: 2px; color: var(--text-dim); transition: background .1s ease; } .ch-item:hover { background: var(--panel-alt); color: var(--text); } .ch-item .ch-name { display: flex; align-items: center; gap: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ch-item .ch-icon { opacity: .6; flex-shrink: 0; } .ch-item .ch-count { background: var(--panel-alt); color: var(--text-faint); font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; flex-shrink: 0; } .ch-item.skipped { opacity: .6; } .ch-item .skip-tag { font-size: 10px; color: var(--danger); } #search-wrap { padding: 10px; border-top: 1px solid var(--border); flex-shrink: 0; } #search-wrap input { width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--panel-alt); color: var(--text); font-size: 13px; } #search-wrap input:focus { outline: 2px solid var(--accent); } .js-only-note { display: none; font-size: 10px; color: var(--text-faint); margin-top: 6px; } /* Main pane */ #main { flex: 1; display: flex; flex-direction: column; min-width: 0; } .panel { display: none; flex-direction: column; flex: 1; min-height: 0; } .panel-header { padding: 12px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; flex-shrink: 0; } .panel-header h2 { margin: 0; font-size: 15px; font-weight: 700; } .count-pill { background: var(--panel-alt); border: 1px solid var(--border); border-radius: 999px; padding: 2px 10px; font-size: 12px; color: var(--text-dim); } .panel-body { flex: 1; overflow-y: auto; padding: 16px 20px; } .msg { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 10px; } .msg.hidden-by-search { display: none; } .msg .msg-top { display: flex; justify-content: space-between; color: var(--text-faint); font-size: 11px; margin-bottom: 6px; } .msg .msg-content { white-space: pre-wrap; word-break: break-word; line-height: 1.45; } .msg .msg-content.redacted { color: var(--text-faint); font-style: italic; } .attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } .attachment-chip { display: flex; align-items: center; gap: 6px; background: var(--panel-alt); border: 1px solid var(--border); border-radius: 8px; padding: 5px 10px; font-size: 12px; color: var(--text-dim); text-decoration: none; max-width: 260px; } .attachment-chip:hover { border-color: var(--accent); color: var(--text); } .attachment-img { max-width: 320px; max-height: 240px; border-radius: 8px; margin-top: 8px; display: block; border: 1px solid var(--border); } .sticker-chip { background: var(--accent-soft); color: var(--accent); border-radius: 8px; padding: 4px 9px; font-size: 12px; margin-top: 6px; display: inline-block; } .empty-state { text-align: center; color: var(--text-faint); padding: 60px 20px; } .skip-banner { background: rgba(250,166,26,0.10); border: 1px solid rgba(250,166,26,0.3); color: var(--warn); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 12px; font-size: 13px; } #footer { padding: 8px 20px; border-top: 1px solid var(--border); color: var(--text-faint); font-size: 11px; text-align: center; flex-shrink: 0; } .brand-ruby { color: var(--ruby); font-weight: 700; } ::-webkit-scrollbar { width: 10px; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 6px; } ::-webkit-scrollbar-track { background: transparent; } @media (max-width: 700px) { html, body { font-size: 15px; } #header { padding: 12px 14px; gap: 10px; } .avatar, .avatar-fallback { width: 40px; height: 40px; } #header .username { font-size: 14px; } #header .id-block { min-width: 0; } #header .stats { order: 3; width: 100%; margin-left: 0; justify-content: space-around; gap: 10px; } #header .stat .num { font-size: 16px; } #header .meta-row { order: 4; } /* Stack sidebar above main content, and turn the channel list into a horizontally-scrolling strip of chips instead of a tall vertical list — the classic mobile pattern for a channel/tab picker. */ #body { flex-direction: column; } #sidebar { width: 100%; max-height: none; border-right: none; border-bottom: 1px solid var(--border); } #sidebar .sidebar-title { padding: 10px 14px 4px; } #channel-list { display: flex; flex-direction: row; flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; gap: 6px; padding: 4px 10px; } .ch-item { flex: 0 0 auto; margin-bottom: 0; } .ch-item .ch-name { white-space: nowrap; } #search-wrap { padding: 8px 10px; } #main { width: 100%; } .panel-header { padding: 10px 14px; } .panel-body { padding: 12px 14px; } .attachment-img { max-width: 100%; } } """ SEARCH_SCRIPT = r""" (function () { var box = document.getElementById('search'); var note = document.getElementById('js-only-note'); if (!box) return; if (note) note.style.display = 'block'; box.addEventListener('input', function () { var term = box.value.toLowerCase(); var msgs = document.querySelectorAll('.msg[data-content]'); for (var i = 0; i < msgs.length; i++) { var el = msgs[i]; var match = !term || el.getAttribute('data-content').indexOf(term) !== -1; el.classList.toggle('hidden-by-search', !match); } }); })(); """ def _badge_class(reason: str) -> str: return { "left": "badge-left", "kicked": "badge-kicked", "banned": "badge-banned", "manual": "badge-manual", }.get(reason, "badge-left") def _badge_label(reason: str) -> str: return { "left": "Left", "kicked": "Kicked", "banned": "Banned", "manual": "Manual", }.get(reason, reason.title()) CHANNEL_ICON = {"text": "#", "thread": "\U0001F9F5", "voice": "\U0001F50A"} def _esc(value: Any) -> str: return html_lib.escape(str(value), quote=True) def _render_message(msg: Dict[str, Any], redact: bool) -> str: content = msg.get("content") or "" search_key = _esc(content.lower()) if not redact else "" parts = [f'
'] parts.append( f'
{_esc(msg.get("timestamp", ""))}' f'#{_esc(msg.get("id", ""))}
' ) if redact: parts.append('
[content redacted by server configuration]
') else: text = content if content else ("" if msg.get("attachments") else "[no text content]") parts.append(f'
{_esc(text)}
') attachments = msg.get("attachments") or [] if attachments: parts.append('
') for att in attachments: if att.get("is_image"): parts.append( f'' ) else: size = att.get("size", "") parts.append( f'' f'\U0001F4CE {_esc(att.get("filename", "file"))}' f'{f" ({_esc(size)})" if size else ""}' ) parts.append("
") for sticker in msg.get("stickers") or []: parts.append(f'✨ {_esc(sticker)}') parts.append("
") 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'
This channel was skipped: {reason}
') else: messages = channel.get("messages") or [] if not messages: body_parts.append('
No messages recorded for this channel.
') else: for msg in messages: body_parts.append(_render_message(msg, redact)) return ( f'
' f'

{icon} {name}

' f'{count} message{"s" if count != 1 else ""}
' f'
{"".join(body_parts)}
' f"
" ) 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 = 'skipped' cls = "ch-item skipped" else: count = channel.get("message_count", 0) right = f'{count}' cls = "ch-item" return ( f'" ) def generate_report_html( *, guild_name: str, member_name: str, member_id: int, reason: str, event_time_str: str, channels: List[Dict[str, Any]], avatar_data_uri: Optional[str] = None, moderator: str = None, mod_reason: str = None, dry_run: bool = False, reveal_content: bool = True, version: str = "1.0.0", generated_at: str = "", ) -> str: """Build the standalone HTML report string. Everything (stats, avatar, channel list, messages) is rendered directly into the markup here in Python — there is no client-side rendering step, so the report displays fully correctly even with JavaScript disabled or blocked. ``channels`` is a list of dicts shaped like: { "id": str, "name": str, "type": "text"|"thread"|"voice", "skipped": bool, "skip_reason": str | None, "message_count": int, "messages": [ {"id": str, "timestamp": str, "content": str, "attachments": [{"filename", "url", "size", "is_image"}], "stickers": [str]}, ... ], } """ total_deleted = sum(c["message_count"] for c in channels if not c.get("skipped")) channels_affected = sum(1 for c in channels if not c.get("skipped") and c["message_count"] > 0) channels_skipped = sum(1 for c in channels if c.get("skipped")) # Only list channels that are actually relevant to this event — a full # server-wide sidebar of untouched channels adds nothing but clutter. # Affected channels (by message count, descending) come first; skipped ones last. relevant = [c for c in channels if c.get("skipped") or c.get("message_count", 0) > 0] relevant.sort(key=lambda c: (c.get("skipped", False), -c.get("message_count", 0))) meta_bits = [f"Server: {html_lib.escape(guild_name)}"] if moderator: meta_bits.append(f"By: {html_lib.escape(moderator)}") if mod_reason: meta_bits.append(f"Reason: {html_lib.escape(mod_reason)}") meta_line = " • ".join(meta_bits) if avatar_data_uri: avatar_html = f'avatar' else: initial = (member_name.strip()[:1] or "?").upper() avatar_html = f'
{_esc(initial)}
' dryrun_badge = ' Dry Run' 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'') 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/#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 = '
Nothing was deleted for this event.
' sidebar_content = '
No channels affected.
' title = f"Ruby — GhostWipe Report — {html_lib.escape(member_name)}" doc = f""" {title} {"".join(radios)}
{main_content}
""" return doc