diff --git a/bun.lockb b/bun.lockb index c3fd6b9..abf75ad 100755 Binary files a/bun.lockb and b/bun.lockb differ 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({