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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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
+
+
+
+
+
+
+
+ Text Watch
create / edit / deleteSelect 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) => (
-

- ))}
-
- ) : 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 ? (
-

- ) : null}
-
- ))}
-
- ) : null}
-
- {metadata.attachments?.length ? (
-
- ) : 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({