diff --git a/.gitignore b/.gitignore
index e6584ce..845ff98 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,4 @@ node_modules
recordings
.env
dist/
-.muxer-queue.db
\ No newline at end of file
+.muxer-queue.**
\ No newline at end of file
diff --git a/docs/superpowers/plans/2026-05-13-backlog-sync-rich-metadata.md b/docs/superpowers/plans/2026-05-13-backlog-sync-rich-metadata.md
new file mode 100644
index 0000000..303fa18
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-13-backlog-sync-rich-metadata.md
@@ -0,0 +1,64 @@
+# Backlog Sync Rich Metadata Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Fetch prior Discord messages up to 24 hours on startup, persist rich Discord-client-like metadata, and render rich message content in homepage tabs.
+
+**Architecture:** Add `messageMetadata.ts` for reusable extraction, `backlogSync.ts` for bounded startup history fetch, reuse existing store/uploader. UI reads metadata JSON and renders stickers, embeds, attachments/replies/thread badges.
+
+**Tech Stack:** Bun, TypeScript, discord.js-selfbot-v13, bun:sqlite, Express/WebSocket, vanilla HTML/CSS/JS.
+
+---
+
+### Task 1: Extract rich message metadata
+
+**Files:**
+- Create: `src/moderation/messageMetadata.ts`
+- Modify: `src/moderation/messageCapture.ts`
+
+- [ ] Create helper functions: `getMessageLocation`, `getStickerMetadata`, `getEmbedMetadata`, `getAttachmentMetadata`, `getMessageMetadata`, `getDisplayContent`.
+- [ ] Replace duplicate capture helper logic with imports from `messageMetadata.ts`.
+- [ ] Verify: `bun run typecheck`.
+
+### Task 2: Make message inserts idempotent
+
+**Files:**
+- Modify: `src/moderation/messageStore.ts`
+
+- [ ] Change message insert to `INSERT OR IGNORE` so backlog sync and live events do not conflict.
+- [ ] Change attachment insert to `INSERT OR IGNORE`.
+- [ ] Verify: `bun run typecheck && bun run test`.
+
+### Task 3: Add backlog sync
+
+**Files:**
+- Create: `src/moderation/backlogSync.ts`
+- Modify: `src/index.ts`
+- Modify: `src/config.ts`
+- Modify: `.env.example`
+
+- [ ] Add config: `BACKLOG_SYNC_HOURS=24`, `BACKLOG_SYNC_BATCH_SIZE=100`.
+- [ ] Fetch text/thread channels from monitored guild on ready.
+- [ ] For each channel/thread, page `channel.messages.fetch({ limit, before })` until older than cutoff.
+- [ ] Store messages with rich metadata and attachments.
+- [ ] Start sync after registering live capture; run async and log progress.
+- [ ] Verify: `bun run typecheck && bun run test`.
+
+### Task 4: Render richer UI
+
+**Files:**
+- Modify: `public/index.html`
+
+- [ ] Render metadata embeds as embed cards.
+- [ ] Render attachments as inline previews/links in Text tab.
+- [ ] Render reply and thread badges.
+- [ ] Keep sticker rendering.
+- [ ] Verify static JS syntax by typecheck/tests where applicable.
+
+### Task 5: Final verification
+
+**Files:** all touched files
+
+- [ ] Run `bun run typecheck`.
+- [ ] Run `bun run test`.
+- [ ] Verify short DB init with `bun -e 'import("./src/muxer-queue.ts").then((m)=>{const db=m.getDatabase(); db.close(); console.log("sqlite ok")})'`.
diff --git a/public/index.html b/public/index.html
index 6f2750f..4605089 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,508 +1,955 @@
-
-
- Discord Audio Gateway v4
-
-
+ .dot {
+ width: 9px;
+ height: 9px;
+ border-radius: 99px;
+ background: var(--faint);
+ }
+
+ .dot.on { background: var(--green); box-shadow: 0 0 16px var(--green); }
+ .dot.warn { background: var(--yellow); box-shadow: 0 0 16px var(--yellow); }
+
+ .tab-panel {
+ position: sticky;
+ top: 14px;
+ z-index: 5;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px;
+ border-radius: 24px;
+ margin-bottom: 18px;
+ }
+
+ .tabs {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ }
+
+ .tab-btn {
+ border: 1px solid transparent;
+ color: var(--muted);
+ background: transparent;
+ border-radius: 16px;
+ padding: 12px 16px;
+ cursor: pointer;
+ font-weight: 800;
+ transition: 160ms ease;
+ }
+
+ .tab-btn:hover { color: var(--text); background: rgba(255,255,255,0.06); }
+ .tab-btn.active {
+ color: #061014;
+ background: linear-gradient(135deg, var(--cyan), var(--green));
+ box-shadow: 0 12px 28px rgba(0,229,255,0.18);
+ }
+
+ .filter-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--muted);
+ font-size: 13px;
+ }
+
+ select {
+ min-width: 240px;
+ color: var(--text);
+ background: rgba(5,8,14,0.78);
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ padding: 11px 14px;
+ outline: none;
+ }
+
+ .grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 18px;
+ }
+
+ .tab-content { display: none; }
+ .tab-content.active { display: block; }
+
+ .voice-layout {
+ display: grid;
+ grid-template-columns: 380px 1fr;
+ gap: 18px;
+ }
+
+ .content-card {
+ border-radius: 28px;
+ padding: 22px;
+ }
+
+ .card-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 18px;
+ }
+
+ .card-title h2 {
+ margin: 0;
+ font-family: "Archivo Black", sans-serif;
+ font-size: 26px;
+ letter-spacing: -0.04em;
+ text-transform: uppercase;
+ }
+
+ .mini {
+ color: var(--faint);
+ font: 700 11px/1 "JetBrains Mono", monospace;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ }
+
+ .field-group { display: grid; gap: 8px; margin-bottom: 14px; }
+ .field-group label { color: var(--muted); font-size: 13px; font-weight: 800; }
+
+ .button-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
+
+ .btn {
+ border: 0;
+ border-radius: 16px;
+ padding: 13px 16px;
+ color: #061014;
+ cursor: pointer;
+ font-weight: 900;
+ transition: transform 140ms ease, filter 140ms ease;
+ }
+
+ .btn:hover { transform: translateY(-1px); filter: brightness(1.08); }
+ .btn-primary { background: linear-gradient(135deg, var(--cyan), var(--blue)); color: white; }
+ .btn-success { background: linear-gradient(135deg, var(--green), var(--cyan)); }
+ .btn-danger { background: linear-gradient(135deg, var(--red), #ff9a6b); color: white; }
+
+ .voice-status { color: var(--muted); font-size: 13px; margin-top: 12px; min-height: 20px; }
+
+ .visualizer {
+ display: flex;
+ align-items: flex-end;
+ gap: 5px;
+ height: 130px;
+ padding: 16px;
+ border: 1px solid var(--line);
+ border-radius: 20px;
+ background: rgba(0,0,0,0.22);
+ overflow: hidden;
+ }
+
+ .bar {
+ flex: 1;
+ min-width: 5px;
+ border-radius: 999px;
+ height: 3px;
+ background: linear-gradient(to top, var(--blue), var(--cyan), var(--green));
+ box-shadow: 0 0 18px rgba(0,229,255,0.16);
+ }
+
+ .participants {
+ display: grid;
+ gap: 10px;
+ }
+
+ .user-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: rgba(255,255,255,0.035);
+ }
+
+ .user-item.speaking { border-color: rgba(57,255,136,0.55); background: rgba(57,255,136,0.08); }
+ .user-item img { width: 34px; height: 34px; border-radius: 999px; }
+
+ .feed {
+ display: grid;
+ gap: 12px;
+ }
+
+ .event-card {
+ display: grid;
+ gap: 10px;
+ padding: 16px;
+ border: 1px solid var(--line);
+ border-radius: 20px;
+ background: rgba(255,255,255,0.035);
+ }
+
+ .event-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ }
+
+ .author {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ }
+
+ .avatar {
+ width: 34px;
+ height: 34px;
+ border-radius: 999px;
+ background: linear-gradient(135deg, var(--blue), var(--cyan));
+ flex: 0 0 auto;
+ }
+
+ .avatar img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
+
+ .name { font-weight: 900; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .time { color: var(--faint); font: 600 11px/1 "JetBrains Mono", monospace; white-space: nowrap; }
+ .message-text { color: #dbe6f7; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
+
+ .sticker-strip { display: flex; gap: 10px; flex-wrap: wrap; }
+ .sticker-img { width: 96px; height: 96px; object-fit: contain; border-radius: 16px; background: rgba(0,0,0,0.22); border: 1px solid var(--line); padding: 8px; }
+
+ .badges { display: flex; gap: 8px; flex-wrap: wrap; }
+ .badge {
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ padding: 5px 9px;
+ color: var(--muted);
+ font: 700 10px/1 "JetBrains Mono", monospace;
+ text-transform: uppercase;
+ }
+ .badge.edit { color: var(--yellow); border-color: rgba(255,228,94,0.36); }
+ .badge.delete { color: var(--red); border-color: rgba(255,79,109,0.42); }
+
+ .image-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 14px;
+ }
+
+ .image-card {
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 22px;
+ background: rgba(255,255,255,0.035);
+ }
+
+ .image-preview {
+ height: 180px;
+ display: grid;
+ place-items: center;
+ background: #05070b;
+ color: var(--faint);
+ font: 700 12px/1 "JetBrains Mono", monospace;
+ }
+
+ .image-preview img { width: 100%; height: 100%; object-fit: cover; }
+ .image-meta { padding: 14px; display: grid; gap: 8px; }
+ .filename { font-size: 13px; font-weight: 900; word-break: break-word; }
+ .link { color: var(--cyan); text-decoration: none; font-weight: 900; }
+ .link:hover { text-decoration: underline; }
+
+ .empty {
+ padding: 34px;
+ text-align: center;
+ color: var(--faint);
+ border: 1px dashed var(--line);
+ border-radius: 22px;
+ }
+
+ .error {
+ display: none;
+ margin-bottom: 14px;
+ padding: 12px 14px;
+ color: #ffd8df;
+ border: 1px solid rgba(255,79,109,0.5);
+ border-radius: 16px;
+ background: rgba(255,79,109,0.12);
+ }
+
+ @media (max-width: 980px) {
+ .hero, .voice-layout { grid-template-columns: 1fr; }
+ .tab-panel { align-items: stretch; flex-direction: column; }
+ .filter-row { align-items: stretch; flex-direction: column; }
+ select { width: 100%; min-width: 0; }
+ }
+
-
-
Discord Gateway v4
-
Optimized 24kHz Mono Bridge
+
+
+
+
Discord moderation command center
+
Voice. Text. Image. One Watch Floor.
+
Single-page watcher for live voice bridge, captured messages, and uploaded image evidence. Built for dense moderation review without jumping between pages.
+
+
+
WebSocket Connecting
+
Voice Link Not connected
+
Active Tab Voice
+
+
-
-
- Guild
-
-
-
- Voice Channel
-
-
-
- Join Channel
- Disconnect
-
-
Not connected
+
+
+ Voice
+ Text
+ Images
+
+
+ Channel / Thread
+ Select channel
+
+
+
+
+
+
+
+
+
Voice Control bridge
+
+ Guild
+
+
+
+ Voice Channel
+
+
+
+ Join
+ Disconnect
+
+
Idle
-
-
+
+
Live Audio speaker off
+
-
-
- Speaker Off
Join Listen Channel
+
+
+
-
-
+
+
Participants speaking now
+
+
+
-
+ connectWebSocket();
+ loadGuilds().then(refreshStatus).catch((error) => showError(error.message));
+ setInterval(() => {
+ if (state.activeTab === 'text') fetchText().catch(() => {});
+ if (state.activeTab === 'images') fetchImages().catch(() => {});
+ }, 7000);
+
diff --git a/src/moderation/messageCapture.ts b/src/moderation/messageCapture.ts
index c046ffa..4e8ad43 100644
--- a/src/moderation/messageCapture.ts
+++ b/src/moderation/messageCapture.ts
@@ -8,29 +8,73 @@ import type { MessageRecord, AttachmentRecord } from "./types";
const logger = createChildLogger("message-capture");
+function getMessageLocation(message: Message): {
+ channelId: string;
+ threadId: string | null;
+ threadName: string | null;
+} {
+ const channel = message.channel as TextChannel | ThreadChannel;
+ if (!channel.isThread?.()) {
+ return { channelId: message.channelId, threadId: null, threadName: null };
+ }
+
+ return {
+ channelId: channel.parentId ?? message.channelId,
+ threadId: channel.id,
+ threadName: channel.name,
+ };
+}
+
+function getStickerMetadata(message: Message): Array<{
+ id: string;
+ name: string;
+ url: string;
+}> {
+ return Array.from(message.stickers.values()).map((sticker) => ({
+ id: sticker.id,
+ name: sticker.name,
+ url: sticker.url,
+ }));
+}
+
+function getDisplayContent(message: Message): string {
+ if (message.content.trim().length > 0) return message.content;
+
+ const stickers = getStickerMetadata(message);
+ if (stickers.length > 0) {
+ return stickers.map((sticker) => `[Sticker: ${sticker.name}]`).join(" ");
+ }
+
+ return "";
+}
+
async function captureMessage(
db: SqliteDatabase,
message: Message,
type: "text" | "edited" | "deleted",
): Promise
{
- const channel = message.channel as TextChannel | ThreadChannel;
- const threadId = channel.isThread?.() ? channel.id : null;
+ const location = getMessageLocation(message);
+ const stickers = getStickerMetadata(message);
+ const metadata = {
+ stickers,
+ threadName: location.threadName,
+ };
const messageRecord: MessageRecord = {
id: message.id,
guild_id: message.guildId!,
- channel_id: message.channelId,
- thread_id: threadId,
+ channel_id: location.channelId,
+ thread_id: location.threadId,
user_id: message.author!.id,
username: message.author!.username,
avatar_url: message.author!.avatarURL() || null,
- content: message.content,
+ content: getDisplayContent(message),
edited_content: null,
created_at: message.createdTimestamp,
edited_at: null,
deleted_at: null,
type,
- metadata: null,
+ metadata: JSON.stringify(metadata),
};
insertMessage(db, messageRecord);
@@ -38,13 +82,7 @@ async function captureMessage(
const broadcaster = globalThis as any;
if (broadcaster.broadcastMessageCreated) {
broadcaster.broadcastMessageCreated({
- id: message.id,
- channel_id: message.channelId,
- user_id: message.author!.id,
- username: message.author!.username,
- avatar_url: message.author!.avatarURL() || null,
- content: message.content,
- created_at: message.createdTimestamp,
+ ...messageRecord,
type: "text",
});
}
@@ -55,7 +93,8 @@ async function captureMessage(
id: attachment.id,
message_id: message.id,
guild_id: message.guildId!,
- channel_id: message.channelId,
+ channel_id: location.channelId,
+ thread_id: location.threadId,
user_id: message.author!.id,
filename: attachment.name || "unknown",
size: attachment.size,
@@ -77,7 +116,7 @@ async function captureMessage(
id: attachment.id,
message_id: message.id,
filename: attachment.name || "unknown",
- channel_id: message.channelId,
+ channel_id: location.channelId,
created_at: Date.now(),
});
}
@@ -129,13 +168,13 @@ export function registerMessageCapture(client: Client, db: SqliteDatabase): void
if (existing) {
const editedAt = Date.now();
- updateMessageAsEdited(db, newMessage.id, newMessage.content || "", editedAt);
+ updateMessageAsEdited(db, newMessage.id, getDisplayContent(newMessage as Message), editedAt);
const broadcaster = globalThis as any;
if (broadcaster.broadcastMessageUpdated) {
broadcaster.broadcastMessageUpdated({
id: newMessage.id,
- edited_content: newMessage.content || "",
+ edited_content: getDisplayContent(newMessage as Message),
edited_at: editedAt,
});
}
diff --git a/src/moderation/messageStore.ts b/src/moderation/messageStore.ts
index 5b0b377..87adfc5 100644
--- a/src/moderation/messageStore.ts
+++ b/src/moderation/messageStore.ts
@@ -96,12 +96,12 @@ export function getMessagesByChannel(
try {
const stmt = db.prepare(`
SELECT * FROM messages
- WHERE channel_id = ?
+ WHERE channel_id = ? OR thread_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`);
- const rows = stmt.all(channelId, limit, offset) as MessageRecord[];
+ const rows = stmt.all(channelId, channelId, limit, offset) as MessageRecord[];
return rows;
} catch (error) {
logger.error(
@@ -116,9 +116,9 @@ export function insertAttachment(db: SqliteDatabase, attachment: AttachmentRecor
try {
const stmt = db.prepare(`
INSERT INTO attachments (
- id, message_id, guild_id, channel_id, user_id, filename, size, type,
+ id, message_id, guild_id, channel_id, thread_id, user_id, filename, size, type,
discord_url, uploaded_url, upload_status, upload_error, created_at, uploaded_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
@@ -126,6 +126,7 @@ export function insertAttachment(db: SqliteDatabase, attachment: AttachmentRecor
attachment.message_id,
attachment.guild_id,
attachment.channel_id,
+ attachment.thread_id,
attachment.user_id,
attachment.filename,
attachment.size,
@@ -157,12 +158,12 @@ export function getAttachmentsByChannel(
try {
const stmt = db.prepare(`
SELECT * FROM attachments
- WHERE channel_id = ?
+ WHERE channel_id = ? OR thread_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`);
- const rows = stmt.all(channelId, limit, offset) as AttachmentRecord[];
+ const rows = stmt.all(channelId, channelId, limit, offset) as AttachmentRecord[];
return rows;
} catch (error) {
logger.error(
diff --git a/src/moderation/types.ts b/src/moderation/types.ts
index 9b49d61..f1003b6 100644
--- a/src/moderation/types.ts
+++ b/src/moderation/types.ts
@@ -20,6 +20,7 @@ export interface AttachmentRecord {
message_id: string;
guild_id: string;
channel_id: string;
+ thread_id: string | null;
user_id: string;
filename: string;
size: number;
diff --git a/src/muxer-queue.ts b/src/muxer-queue.ts
index b9d241e..6ba2e50 100644
--- a/src/muxer-queue.ts
+++ b/src/muxer-queue.ts
@@ -84,6 +84,7 @@ function initializeDatabase(): SqliteDatabase {
message_id TEXT NOT NULL,
guild_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
+ thread_id TEXT,
user_id TEXT NOT NULL,
filename TEXT NOT NULL,
size INTEGER NOT NULL,
@@ -102,6 +103,12 @@ function initializeDatabase(): SqliteDatabase {
CREATE INDEX IF NOT EXISTS idx_attachments_status ON attachments(upload_status);
`);
+ try {
+ database.exec("ALTER TABLE attachments ADD COLUMN thread_id TEXT");
+ } catch {
+ // Column already exists on databases initialized after the moderation schema was added.
+ }
+
return database;
}
diff --git a/src/voiceController.ts b/src/voiceController.ts
index 7f3def1..8ff5b50 100644
--- a/src/voiceController.ts
+++ b/src/voiceController.ts
@@ -25,6 +25,12 @@ export interface VoiceChannelSummary {
name: string;
}
+export interface ChannelSummary {
+ id: string;
+ name: string;
+ type: string;
+}
+
export class VoiceController {
private activeGuildId: string | null = null;
private activeChannelId: string | null = null;
@@ -63,6 +69,27 @@ export class VoiceController {
.sort((a, b) => a.name.localeCompare(b.name));
}
+ async listWatchableChannels(guildId: string): Promise {
+ const guild = this.getGuild(guildId);
+ await guild.channels.fetch().catch(() => null);
+
+ return guild.channels.cache
+ .filter((channel) =>
+ ["GUILD_TEXT", "GUILD_PUBLIC_THREAD", "GUILD_PRIVATE_THREAD"].includes(
+ channel.type,
+ ),
+ )
+ .map((channel) => {
+ const parentName = channel.isThread?.() ? channel.parent?.name : null;
+ return {
+ id: channel.id,
+ name: parentName ? `${parentName} / ${channel.name}` : channel.name,
+ type: channel.type,
+ };
+ })
+ .sort((a, b) => a.name.localeCompare(b.name));
+ }
+
async connect(guildId: string, channelId: string): Promise {
if (!this.client.isReady()) {
throw new AppError(
diff --git a/src/webserver.ts b/src/webserver.ts
index 16a53f2..2e3c067 100644
--- a/src/webserver.ts
+++ b/src/webserver.ts
@@ -106,6 +106,14 @@ export function startWebserver(
}
});
+ app.get("/api/guilds/:guildId/channels", async (req, res, next) => {
+ try {
+ res.json(await voiceController.listWatchableChannels(req.params.guildId));
+ } catch (error) {
+ next(error);
+ }
+ });
+
app.post("/api/connect", async (req, res, next) => {
try {
const { guildId, channelId } = req.body as {