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.

+
+
+
WebSocketConnecting
+
Voice LinkNot connected
+
Active TabVoice
+
+
-
-
- - -
-
- - -
-
- - -
-
Not connected
+ + +
+ +
+
+
+

Voice Control

bridge
+
+ + +
+
+ + +
+
+ + +
+
Idle
-
-
-
- Connecting... -
+
+

Live Audio

speaker off
+
-
-
- -
- Speaker Off +
+
+
-
-

Participants

-
-
-
+
+

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 {