From 0bdab3b446546d3fae91f2d85d0e9cc91394c267 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 14 May 2026 00:14:25 +0700 Subject: [PATCH] Refactor dashboard page: replace React component with static HTML, remove unused dashboardPage.tsx, and update webserver to serve new index.html --- bun.lockb | Bin 163506 -> 159290 bytes package.json | 6 - public/audio-worklet.js | 42 --- public/dashboard.html | 528 -------------------------------------- public/dashboard.js | 496 ----------------------------------- public/index.html | 102 +++++++- src/web/dashboardPage.tsx | 218 ---------------- src/webserver.ts | 38 +-- 8 files changed, 105 insertions(+), 1325 deletions(-) delete mode 100644 public/audio-worklet.js delete mode 100644 public/dashboard.html delete mode 100644 public/dashboard.js delete mode 100644 src/web/dashboardPage.tsx diff --git a/bun.lockb b/bun.lockb index c3fd6b9c7e74ecad65a31dd41d7b6d4bc3dd4440..abf75ad393e148c01cebb269dfbc57d96d8dbb41 100755 GIT binary patch delta 28919 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 QuC8Tu&a3#m_HL2?1wbH?-v9sr delta 31266 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 UiM5bd0~GZs<%aovrCDYF2ev|bE&u=k diff --git a/package.json b/package.json index 989a972..eedd919 100644 --- a/package.json +++ b/package.json @@ -24,17 +24,13 @@ "express": "^5.2.1", "fluent-ffmpeg": "^2.1.3", "helmet": "^8.1.0", - "howler": "^2.2.4", "libsodium-wrappers": "^0.8.2", "p-retry": "^6.2.0", "pino": "^9.4.0", "pino-http": "^11.0.0", "prism-media": "2.0.0-alpha.0", "prom-client": "^15.1.3", - "react": "^19.2.6", - "react-dom": "^19.2.6", "sodium-native": "^4.3.2", - "tone": "^15.1.22", "ws": "^8.20.1", "zod": "^4.4.3" }, @@ -43,8 +39,6 @@ "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", "@types/fluent-ffmpeg": "^2.1.28", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", "bun-types": "latest", "pino-pretty": "^10.3.1", diff --git a/public/audio-worklet.js b/public/audio-worklet.js deleted file mode 100644 index 91f398c..0000000 --- a/public/audio-worklet.js +++ /dev/null @@ -1,42 +0,0 @@ -class MicrophoneProcessor extends AudioWorkletProcessor { - constructor() { - super(); - this.noiseGateThreshold = 0.01; - this.noiseGateHoldFrames = 3; - this.noiseGateHold = 0; - } - - process(inputs, outputs, parameters) { - const input = inputs[0]; - if (!input || input.length === 0) return true; - - const inputData = input[0]; - const output = outputs[0]; - if (output && output.length > 0) { - output[0].set(inputData); - } - - let sum = 0; - for (let i = 0; i < inputData.length; i++) { - sum += inputData[i] * inputData[i]; - } - const rms = Math.sqrt(sum / inputData.length); - - if (rms < this.noiseGateThreshold && this.noiseGateHold <= 0) { - this.port.postMessage({ type: 'audio', rms: 0, data: null }); - return true; - } - - this.noiseGateHold = rms >= this.noiseGateThreshold ? this.noiseGateHoldFrames : this.noiseGateHold - 1; - - const pcm = new Int16Array(inputData.length); - for (let i = 0; i < inputData.length; i++) { - pcm[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767; - } - - this.port.postMessage({ type: 'audio', rms, data: pcm.buffer }, [pcm.buffer]); - return true; - } -} - -registerProcessor('microphone-processor', MicrophoneProcessor); diff --git a/public/dashboard.html b/public/dashboard.html deleted file mode 100644 index 759e1b0..0000000 --- a/public/dashboard.html +++ /dev/null @@ -1,528 +0,0 @@ - - - - - - Moderation Dashboard - - - -
-
-

🛡️ Moderation Dashboard

-
-
-
- Connecting... -
-
-
- -
- - -
- -
- - - -
- - - -
-
-
- -
-
-
- -
-
-
-
- - - - diff --git a/public/dashboard.js b/public/dashboard.js deleted file mode 100644 index 9f1f2c7..0000000 --- a/public/dashboard.js +++ /dev/null @@ -1,496 +0,0 @@ -// Tone.js loaded via CDN as global object -const bootstrapData = JSON.parse(document.getElementById('__DASHBOARD_DATA__')?.textContent || '{}'); -const state = { - socket: null, - activeTab: 'voice', - selectedChannel: bootstrapData.selectedChannelId || '', - text: bootstrapData.messages || [], - isStreaming: false, - isListening: false, - audioContextTransmit: null, - audioContextListen: null, - processor: null, - nextStartTime: 0, - userTimelines: new Map(), -}; - -const SAMPLE_RATE = 24000; -const CHANNELS = 1; -const NOISE_GATE_THRESHOLD = 0.01; - -const el = { - wsDot: document.getElementById('wsDot'), - wsStatusText: document.getElementById('wsStatusText'), - activeTabLabel: document.getElementById('activeTabLabel'), - errorBox: document.getElementById('errorBox'), - guildSelect: document.getElementById('guildSelect'), - channelSelect: document.getElementById('channelSelect'), - channelFilter: document.getElementById('channelFilter'), - joinVoiceBtn: document.getElementById('joinVoiceBtn'), - disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'), - voiceStatusText: document.getElementById('voiceStatusText'), - voiceStatusNote: document.getElementById('voiceStatusNote'), - toggleBtn: document.getElementById('toggleBtn'), - listenBtn: document.getElementById('listenBtn'), - listenStatus: document.getElementById('listenStatus'), - visualizer: document.getElementById('visualizer'), - userList: document.getElementById('userList'), - textList: document.getElementById('textList'), -}; - -for (let i = 0; i < 32; i++) { - const bar = document.createElement('div'); - bar.className = 'bar'; - el.visualizer.appendChild(bar); -} -const bars = [...document.querySelectorAll('.bar')]; - -async function apiRequest(url, options = {}) { - const response = await fetch(url, { - headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, - ...options, - }); - if (!response.ok) { - const error = await response.json().catch(() => ({ message: response.statusText })); - throw new Error(error.message || response.statusText); - } - return response.json(); -} - -function showError(message) { - el.errorBox.textContent = message; - el.errorBox.style.display = 'block'; - setTimeout(() => { el.errorBox.style.display = 'none'; }, 4500); -} - -function renderOptions(select, items, placeholder) { - select.replaceChildren(); - const first = document.createElement('option'); - first.value = ''; - first.textContent = placeholder; - select.appendChild(first); - for (const item of items) { - const option = document.createElement('option'); - option.value = item.id; - option.textContent = item.name; - select.appendChild(option); - } -} - -async function loadGuilds() { - const guilds = bootstrapData.guilds || await apiRequest('/api/guilds'); - renderOptions(el.guildSelect, guilds, 'Select guild'); - const guildId = bootstrapData.selectedGuildId || guilds[0]?.id || ''; - if (guildId) { - el.guildSelect.value = guildId; - await loadChannels(guildId); - } -} - -async function loadChannels(guildId) { - const useBootstrap = guildId === bootstrapData.selectedGuildId; - const [voiceChannels, watchChannels] = await Promise.all([ - useBootstrap && bootstrapData.voiceChannels ? bootstrapData.voiceChannels : apiRequest(`/api/guilds/${guildId}/voice-channels`), - useBootstrap && bootstrapData.watchChannels ? bootstrapData.watchChannels : apiRequest(`/api/guilds/${guildId}/channels`), - ]); - renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); - renderOptions(el.channelFilter, watchChannels, 'Select channel'); - el.channelFilter.value = state.selectedChannel; - apiRequest(`/api/guilds/${guildId}/threads`) - .then((threads) => appendOptions(el.channelFilter, threads)) - .catch((error) => showError(`Thread discovery failed: ${error.message}`)); -} - -function appendOptions(select, items) { - const existing = new Set([...select.options].map((option) => option.value)); - for (const item of items) { - if (existing.has(item.id)) continue; - const option = document.createElement('option'); - option.value = item.id; - option.textContent = item.name; - select.appendChild(option); - } -} - -async function refreshStatus() { - try { - const status = await apiRequest('/api/status'); - el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected'; - el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle'; - } catch (error) { - showError(error.message); - } -} - -async function connectVoice() { - const guildId = el.guildSelect.value; - const channelId = el.channelSelect.value; - if (!guildId || !channelId) return showError('Select guild and voice channel first'); - const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) }); - el.voiceStatusText.textContent = status.activeChannelName || 'Connected'; - el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`; -} - -async function disconnectVoice() { - await apiRequest('/api/disconnect', { method: 'POST' }); - await refreshStatus(); -} - -function connectWebSocket() { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - state.socket = new WebSocket(`${protocol}//${window.location.host}/ws`); - state.socket.binaryType = 'arraybuffer'; - - state.socket.onopen = () => { - el.wsDot.classList.add('on'); - el.wsStatusText.textContent = 'Connected'; - }; - - state.socket.onclose = () => { - el.wsDot.classList.remove('on'); - el.wsStatusText.textContent = 'Reconnecting'; - setTimeout(connectWebSocket, 2500); - }; - - state.socket.onerror = () => { - el.wsDot.classList.remove('on'); - el.wsDot.classList.add('warn'); - el.wsStatusText.textContent = 'Socket error'; - }; - - state.socket.onmessage = (event) => { - if (typeof event.data === 'string') { - handleJsonEvent(event.data); - return; - } - console.log('[listen] Received PCM packet:', event.data.byteLength, 'bytes, isListening:', state.isListening); - if (state.isListening) { - console.log('[listen] Playing PCM...'); - playPcm(event.data); - } - }; -} - -function handleJsonEvent(raw) { - const message = JSON.parse(raw); - if (message.type === 'user_state') return renderUsers(message.users || []); - if (message.type === 'message_created') { - state.text.unshift(message.data); - renderText(); - } - if (message.type === 'message_updated') { - const item = state.text.find((entry) => entry.id === message.data.id); - if (item) Object.assign(item, { edited_content: message.data.edited_content, edited_at: message.data.edited_at, type: 'edited' }); - renderText(); - } - if (message.type === 'message_deleted') { - const item = state.text.find((entry) => entry.id === message.data.id); - if (item) Object.assign(item, { deleted_at: message.data.deleted_at, type: 'deleted' }); - renderText(); - } - if (message.type === 'attachment_uploaded') fetchText(); -} - -function renderUsers(users) { - el.userList.replaceChildren(); - if (users.length === 0) { - const empty = document.createElement('div'); - empty.className = 'empty'; - empty.textContent = 'No active speakers'; - el.userList.appendChild(empty); - return; - } - for (const user of users) { - const row = document.createElement('div'); - row.className = `user-item${user.speaking ? ' speaking' : ''}`; - const img = document.createElement('img'); - img.src = user.avatar || ''; - img.alt = ''; - const name = document.createElement('span'); - name.textContent = user.username; - row.append(img, name); - el.userList.appendChild(row); - } -} - -async function fetchText() { - if (!state.selectedChannel) return renderText(); - const result = await apiRequest(`/api/messages?channel=${encodeURIComponent(state.selectedChannel)}&type=text&limit=80`); - state.text = result.data || []; - renderText(); -} - -function parseMetadata(value) { - if (!value) return {}; - try { return JSON.parse(value); } catch { return {}; } -} - -function renderText() { - el.textList.replaceChildren(); - if (!state.selectedChannel) return appendEmpty(el.textList, 'Select channel to view text captures'); - if (state.text.length === 0) return appendEmpty(el.textList, 'No text captures yet'); - for (const msg of state.text) { - const metadata = parseMetadata(msg.metadata); - const card = document.createElement('article'); - card.className = 'event-card'; - const head = document.createElement('div'); - head.className = 'event-head'; - const author = document.createElement('div'); - author.className = 'author'; - const avatar = document.createElement('div'); - avatar.className = 'avatar'; - if (msg.avatar_url) { - const img = document.createElement('img'); - img.src = msg.avatar_url; - img.alt = ''; - avatar.appendChild(img); - } - const name = document.createElement('div'); - name.className = 'name'; - name.textContent = msg.username || msg.user_id; - author.append(avatar, name); - const time = document.createElement('div'); - time.className = 'time'; - time.textContent = new Date(msg.created_at).toLocaleString(); - head.append(author, time); - const text = document.createElement('div'); - text.className = 'message-text'; - text.textContent = msg.edited_content || msg.content || '(empty message)'; - const stickers = renderStickers(metadata.stickers || []); - const embeds = renderEmbeds(metadata.embeds || []); - const attachments = renderAttachments(metadata.attachments || []); - const badges = document.createElement('div'); - badges.className = 'badges'; - if (metadata.reference?.messageId) appendBadge(badges, 'reply', ''); - if (msg.thread_id) appendBadge(badges, metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : 'thread', ''); - if (msg.edited_at) appendBadge(badges, 'edited', 'edit'); - if (msg.deleted_at) appendBadge(badges, 'deleted', 'delete'); - card.append(head, text); - if (stickers.childElementCount > 0) card.appendChild(stickers); - if (embeds.childElementCount > 0) card.appendChild(embeds); - if (attachments.childElementCount > 0) card.appendChild(attachments); - card.appendChild(badges); - el.textList.appendChild(card); - } -} - -function renderStickers(stickers) { - const wrap = document.createElement('div'); - wrap.className = 'sticker-strip'; - for (const sticker of stickers) { - const img = document.createElement('img'); - img.className = 'sticker-img'; - img.src = sticker.url; - img.alt = sticker.name; - wrap.appendChild(img); - } - return wrap; -} - -function renderEmbeds(embeds) { - const wrap = document.createElement('div'); - wrap.className = 'feed'; - for (const embed of embeds) { - const card = document.createElement('div'); - card.className = 'embed-card'; - if (embed.title) { - const title = document.createElement(embed.url ? 'a' : 'div'); - title.className = 'embed-title'; - title.textContent = embed.title; - if (embed.url) { - title.href = embed.url; - title.target = '_blank'; - title.rel = 'noreferrer'; - } - card.appendChild(title); - } - if (embed.description) { - const desc = document.createElement('div'); - desc.className = 'embed-description'; - desc.textContent = embed.description; - card.appendChild(desc); - } - for (const field of embed.fields || []) { - const fieldNode = document.createElement('div'); - fieldNode.className = 'embed-description'; - fieldNode.textContent = `${field.name}: ${field.value}`; - card.appendChild(fieldNode); - } - if (embed.image || embed.thumbnail) { - const img = document.createElement('img'); - img.className = 'embed-image'; - img.src = embed.image || embed.thumbnail; - img.alt = embed.title || 'embed image'; - card.appendChild(img); - } - wrap.appendChild(card); - } - return wrap; -} - -function renderAttachments(attachments) { - const wrap = document.createElement('div'); - wrap.className = 'attachment-strip'; - for (const attachment of attachments) { - const link = document.createElement('a'); - link.className = 'attachment-chip'; - link.href = attachment.url; - link.target = '_blank'; - link.rel = 'noreferrer'; - link.textContent = `${attachment.name} (${(attachment.size / 1024).toFixed(1)}KB)`; - wrap.appendChild(link); - } - return wrap; -} - -function appendBadge(parent, label, className) { - const badge = document.createElement('span'); - badge.className = `badge ${className}`; - badge.textContent = label; - parent.appendChild(badge); -} - -function appendEmpty(parent, message) { - const empty = document.createElement('div'); - empty.className = 'empty'; - empty.textContent = message; - parent.appendChild(empty); -} - -async function startStreaming() { - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - state.isStreaming = true; - el.toggleBtn.textContent = 'Stop Transmitting'; - - state.audioContextTransmit = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE }); - const source = state.audioContextTransmit.createMediaStreamSource(stream); - - const analyser = state.audioContextTransmit.createAnalyser(); - analyser.fftSize = 64; - source.connect(analyser); - const dataArray = new Uint8Array(analyser.frequencyBinCount); - - state.processor = state.audioContextTransmit.createScriptProcessor(4096, 1, 1); - source.connect(state.processor); - state.processor.connect(state.audioContextTransmit.destination); - - state.processor.onaudioprocess = (e) => { - if (!state.isStreaming || state.socket?.readyState !== WebSocket.OPEN) return; - - const inputData = e.inputBuffer.getChannelData(0); - const pcmData = new Int16Array(inputData.length); - for (let i = 0; i < inputData.length; i++) { - pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767; - } - state.socket.send(pcmData.buffer); - - analyser.getByteFrequencyData(dataArray); - bars.forEach((bar, index) => { - const percent = (dataArray[index] / 255) * 100; - bar.style.height = `${Math.max(2, percent)}%`; - }); - }; - } catch (err) { - showError(`Microphone access denied: ${err.message}`); - } -} - -function stopStreaming() { - state.isStreaming = false; - if (state.processor) state.processor.disconnect(); - if (state.audioContextTransmit) state.audioContextTransmit.close(); - state.processor = null; - state.audioContextTransmit = null; - el.toggleBtn.textContent = 'Start Transmitting'; - bars.forEach(bar => bar.style.height = '2px'); -} - -function toggleListen() { - state.isListening = !state.isListening; - console.log('[listen] Toggle listen:', state.isListening); - if (state.isListening) { - state.audioContextListen = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE }); - state.userTimelines.clear(); - console.log('[listen] AudioContext created, sampleRate:', SAMPLE_RATE); - el.listenBtn.textContent = 'Leave Listen Channel'; - el.listenStatus.textContent = 'speaker on'; - } else { - state.audioContextListen?.close(); - state.audioContextListen = null; - state.userTimelines.clear(); - el.listenBtn.textContent = 'Join Listen Channel'; - el.listenStatus.textContent = 'speaker off'; - } -} - -function playPcm(arrayBuffer) { - console.log('[listen] playPcm called, isListening:', state.isListening, 'hasContext:', !!state.audioContextListen); - if (!state.isListening || !state.audioContextListen) return; - - const headerView = new DataView(arrayBuffer, 0, 4); - const userIdHash = headerView.getInt32(0, true); - const audioData = arrayBuffer.slice(4); - console.log('[listen] userIdHash:', userIdHash, 'audioDataLength:', audioData.byteLength); - - const int16Array = new Int16Array(audioData); - const float32Array = new Float32Array(int16Array.length); - for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768; - - const audioBuffer = state.audioContextListen.createBuffer(CHANNELS, float32Array.length / CHANNELS, SAMPLE_RATE); - const nowBuffering = audioBuffer.getChannelData(0); - for (let i = 0; i < audioBuffer.length; i++) nowBuffering[i] = float32Array[i]; - - const source = state.audioContextListen.createBufferSource(); - source.buffer = audioBuffer; - source.connect(state.audioContextListen.destination); - - const currentTime = state.audioContextListen.currentTime; - let userNextStartTime = state.userTimelines.get(userIdHash) || 0; - - if (userNextStartTime < currentTime) userNextStartTime = currentTime + 0.05; - console.log('[listen] Starting playback at:', userNextStartTime, 'duration:', audioBuffer.duration); - source.start(userNextStartTime); - userNextStartTime += audioBuffer.duration; - state.userTimelines.set(userIdHash, userNextStartTime); -} - -function updateVisualizer(level) { - bars.forEach((bar, index) => { - const wave = Math.sin(index * 0.55 + Date.now() / 140) * 0.35 + 0.65; - bar.style.height = `${Math.max(3, level * 190 * wave)}px`; - }); -} - -document.querySelectorAll('.tab-btn').forEach((button) => { - button.addEventListener('click', async () => { - document.querySelectorAll('.tab-btn').forEach((item) => item.classList.remove('active')); - document.querySelectorAll('.tab-content').forEach((item) => item.classList.remove('active')); - button.classList.add('active'); - state.activeTab = button.dataset.tab; - document.getElementById(state.activeTab).classList.add('active'); - el.activeTabLabel.textContent = button.textContent; - if (state.activeTab === 'text') await fetchText(); - }); -}); - -el.guildSelect.addEventListener('change', () => loadChannels(el.guildSelect.value).catch((error) => showError(error.message))); -el.joinVoiceBtn.addEventListener('click', () => connectVoice().catch((error) => showError(error.message))); -el.disconnectVoiceBtn.addEventListener('click', () => disconnectVoice().catch((error) => showError(error.message))); -el.toggleBtn.addEventListener('click', () => state.isStreaming ? stopStreaming() : startStreaming()); -el.listenBtn.addEventListener('click', toggleListen); -el.channelFilter.addEventListener('change', async () => { - state.selectedChannel = el.channelFilter.value; - const url = new URL(window.location.href); - if (state.selectedChannel) url.searchParams.set('channel', state.selectedChannel); - else url.searchParams.delete('channel'); - if (el.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value); - window.history.replaceState({}, '', url); - await fetchText().catch((error) => showError(error.message)); -}); - -connectWebSocket(); -loadGuilds().then(refreshStatus).catch((error) => showError(error.message)); -setInterval(() => { - if (state.activeTab === 'text') fetchText().catch(() => {}); -}, 7000); diff --git a/public/index.html b/public/index.html index 7d3e719..ba67da9 100644 --- a/public/index.html +++ b/public/index.html @@ -1 +1,101 @@ - + + + + + + Discord Moderation Watcher + + + + +
+
+
+
Discord moderation watcher
+

Voice. Text. One Watch Floor.

+

Static client with legacy working voice bridge plus captured Discord messages, stickers, embeds, replies, and attachments.

+
+
+
WebSocketConnecting
+
Voice LinkNot connected
+
Active TabVoice
+
+
+ + + +
+ +
+
+
+

Voice Control

bridge
+
+
+
+
Idle
+
+
+

Live Audio

Speaker Off
+
+
+
+
+

Participants

speaking now
+
+ +

Text Watch

create / edit / delete
Select channel to view text captures
+
+ + + + diff --git a/src/web/dashboardPage.tsx b/src/web/dashboardPage.tsx deleted file mode 100644 index cb0c6f4..0000000 --- a/src/web/dashboardPage.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { renderToString } from "react-dom/server"; -import type { MessageRecord } from "../moderation/types"; -import type { ChannelSummary, GuildSummary, VoiceChannelSummary, VoiceStatus } from "../voiceController"; - -interface DashboardProps { - guilds: GuildSummary[]; - voiceChannels: VoiceChannelSummary[]; - watchChannels: ChannelSummary[]; - selectedGuildId: string; - selectedChannelId: string; - messages: MessageRecord[]; - status: VoiceStatus; -} - -function parseMetadata(value: string | null): any { - if (!value) return {}; - try { - return JSON.parse(value); - } catch { - return {}; - } -} - -function safeJson(value: unknown): string { - return JSON.stringify(value).replace(/ -
-
-
- {message.avatar_url ? : null} -
-
{message.username || message.user_id}
-
-
{new Date(message.created_at).toLocaleString()}
-
- -
{content}
- - {metadata.stickers?.length ? ( -
- {metadata.stickers.map((sticker: any) => ( - {sticker.name} - ))} -
- ) : null} - - {metadata.embeds?.length ? ( -
- {metadata.embeds.map((embed: any, index: number) => ( -
- {embed.title ? ( - embed.url ? ( - {embed.title} - ) : ( -
{embed.title}
- ) - ) : null} - {embed.description ?
{embed.description}
: null} - {embed.fields?.map((field: any, fieldIndex: number) => ( -
{field.name}: {field.value}
- ))} - {embed.image || embed.thumbnail ? ( - {embed.title - ) : null} -
- ))} -
- ) : null} - - {metadata.attachments?.length ? ( -
- {metadata.attachments.map((attachment: any) => ( - - {attachment.name} ({(attachment.size / 1024).toFixed(1)}KB) - - ))} -
- ) : null} - -
- {metadata.reference?.messageId ? reply : null} - {message.thread_id ? ( - {metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : "thread"} - ) : null} - {message.edited_at ? edited : null} - {message.deleted_at ? deleted : null} -
- - ); -} - -function DashboardPage(props: DashboardProps) { - return ( -
-
-
-
Discord moderation command center
-

Voice. Text. One Watch Floor.

-

Single-page watcher for live voice bridge and captured Discord messages, including stickers, embeds, replies, and uploaded image evidence inline.

-
-
-
WebSocketConnecting
-
Voice Link{props.status.connected ? props.status.activeChannelName || "Connected" : "Not connected"}
-
Active TabVoice
-
-
- - - -
- -
-
-
-

Voice Control

bridge
-
- - -
-
- - -
-
- - -
-
{props.status.connected ? `Connected to ${props.status.activeChannelName}` : "Idle"}
-
- -
-

Live Audio

speaker off
-
- - -
-
-
-
- -
-

Participants

speaking now
-
-
-
- -
-
-

Text Watch

create / edit / delete
-
- {!props.selectedChannelId ?
Select channel to view text captures
: null} - {props.selectedChannelId && props.messages.length === 0 ?
No text captures yet
: null} - {props.messages.map((message) => )} -
-
-
-
- ); -} - -export function renderDashboardPage(props: DashboardProps): string { - const app = renderToString(); - const bootstrap = safeJson({ - guilds: props.guilds, - voiceChannels: props.voiceChannels, - watchChannels: props.watchChannels, - selectedGuildId: props.selectedGuildId, - selectedChannelId: props.selectedChannelId, - messages: props.messages, - status: props.status, - }); - - return ` - - - - - Discord Moderation Watcher - - - - - - - -
${app}
- - - -`; -} diff --git a/src/webserver.ts b/src/webserver.ts index 821623e..ee04c19 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -10,7 +10,6 @@ import { AppError } from "./errors"; import { createChildLogger, logger } from "./logger"; import { getMetrics, uptimeGauge } from "./metrics"; import { discordPlayer } from "./player"; -import { renderDashboardPage } from "./web/dashboardPage"; import type { VoiceController } from "./voiceController"; import { getDatabase } from "./muxer-queue"; import { getMessagesByChannel, getAttachmentsByChannel } from "./moderation/messageStore"; @@ -71,41 +70,12 @@ export function startWebserver( app.use(pinoHttp({ logger })); app.use(express.json()); - app.get("/", async (req, res, next) => { - try { - const guilds = voiceController.listGuilds(); - const selectedGuildId = - typeof req.query.guild === "string" ? req.query.guild : guilds[0]?.id || ""; - const selectedChannelId = - typeof req.query.channel === "string" ? req.query.channel : ""; - const [voiceChannels, watchChannels] = selectedGuildId - ? await Promise.all([ - voiceController.listVoiceChannels(selectedGuildId), - voiceController.listWatchableChannels(selectedGuildId), - ]) - : [[], []]; - const messages = selectedChannelId - ? getMessagesByChannel(getDatabase(), selectedChannelId, 80, 0) - : []; - - res.type("html").send( - renderDashboardPage({ - guilds, - voiceChannels, - watchChannels, - selectedGuildId, - selectedChannelId, - messages, - status: voiceController.getStatus(), - }), - ); - } catch (error) { - next(error); - } - }); - app.use(express.static(path.join(__dirname, "../public"))); + app.get("/", (_req, res) => { + res.sendFile(path.join(__dirname, "../public/index.html")); + }); + // Health check endpoint app.get("/health", (_req, res) => { res.json({