Refactor dashboard page: replace React component with static HTML, remove unused dashboardPage.tsx, and update webserver to serve new index.html

This commit is contained in:
MythEclipse
2026-05-14 00:14:25 +07:00
parent cbfb99f755
commit 0bdab3b446
8 changed files with 105 additions and 1325 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -24,17 +24,13 @@
"express": "^5.2.1", "express": "^5.2.1",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"howler": "^2.2.4",
"libsodium-wrappers": "^0.8.2", "libsodium-wrappers": "^0.8.2",
"p-retry": "^6.2.0", "p-retry": "^6.2.0",
"pino": "^9.4.0", "pino": "^9.4.0",
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"prism-media": "2.0.0-alpha.0", "prism-media": "2.0.0-alpha.0",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"sodium-native": "^4.3.2", "sodium-native": "^4.3.2",
"tone": "^15.1.22",
"ws": "^8.20.1", "ws": "^8.20.1",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@@ -43,8 +39,6 @@
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/fluent-ffmpeg": "^2.1.28", "@types/fluent-ffmpeg": "^2.1.28",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"bun-types": "latest", "bun-types": "latest",
"pino-pretty": "^10.3.1", "pino-pretty": "^10.3.1",

View File

@@ -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);

View File

@@ -1,528 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Moderation Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
header {
margin-bottom: 30px;
border-bottom: 2px solid #333;
padding-bottom: 20px;
}
h1 {
font-size: 28px;
margin-bottom: 10px;
color: #fff;
}
.status {
display: flex;
gap: 20px;
font-size: 14px;
color: #999;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
}
.status-dot.connected {
background: #4ade80;
}
.controls {
display: flex;
gap: 15px;
margin-bottom: 20px;
align-items: center;
}
select {
padding: 8px 12px;
background: #1a1a1a;
border: 1px solid #333;
color: #e0e0e0;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
}
select:hover {
border-color: #555;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 1px solid #333;
}
.tab {
padding: 12px 20px;
background: none;
border: none;
color: #999;
cursor: pointer;
font-size: 14px;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover {
color: #e0e0e0;
}
.tab.active {
color: #fff;
border-bottom-color: #4ade80;
}
.content {
display: none;
}
.content.active {
display: block;
}
.message-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.message-item {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
padding: 15px;
transition: all 0.2s;
}
.message-item:hover {
border-color: #555;
background: #222;
}
.message-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #333;
flex-shrink: 0;
}
.avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
}
.user-info {
flex: 1;
}
.username {
font-weight: 600;
color: #fff;
font-size: 14px;
}
.timestamp {
font-size: 12px;
color: #666;
}
.message-content {
color: #e0e0e0;
font-size: 14px;
line-height: 1.5;
margin-bottom: 10px;
word-break: break-word;
}
.message-meta {
display: flex;
gap: 15px;
font-size: 12px;
color: #666;
}
.badge {
display: inline-block;
padding: 2px 8px;
background: #333;
border-radius: 3px;
font-size: 11px;
color: #999;
}
.badge.edited {
background: #4a3a00;
color: #ffd700;
}
.badge.deleted {
background: #3a0000;
color: #ff6b6b;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
.image-item {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
overflow: hidden;
transition: all 0.2s;
}
.image-item:hover {
border-color: #555;
}
.image-preview {
width: 100%;
height: 150px;
background: #0a0a0a;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-info {
padding: 12px;
border-top: 1px solid #333;
}
.image-filename {
font-size: 12px;
color: #e0e0e0;
margin-bottom: 8px;
word-break: break-all;
}
.image-meta {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #666;
}
.image-url {
color: #4ade80;
cursor: pointer;
text-decoration: none;
}
.image-url:hover {
text-decoration: underline;
}
.empty {
text-align: center;
padding: 40px;
color: #666;
}
.error {
background: #3a0000;
border: 1px solid #660000;
color: #ff6b6b;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🛡️ Moderation Dashboard</h1>
<div class="status">
<div class="status-item">
<div class="status-dot" id="wsStatus"></div>
<span id="wsStatusText">Connecting...</span>
</div>
</div>
</header>
<div class="controls">
<label for="channelFilter">Filter by Channel:</label>
<select id="channelFilter">
<option value="">All Channels</option>
</select>
</div>
<div class="tabs">
<button class="tab active" data-tab="text">💬 Text Messages</button>
<button class="tab" data-tab="image">🖼️ Images</button>
<button class="tab" data-tab="voice">🎙️ Voice</button>
</div>
<div id="error" class="error" style="display: none;"></div>
<div id="text" class="content active">
<div class="message-list" id="textList"></div>
</div>
<div id="image" class="content">
<div class="image-grid" id="imageGrid"></div>
</div>
<div id="voice" class="content">
<div class="message-list" id="voiceList"></div>
</div>
</div>
<script>
const API_BASE = window.location.origin;
let ws = null;
let selectedChannel = '';
let messageCache = { text: [], image: [], voice: [] };
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
ws.onopen = () => {
updateWSStatus(true);
console.log('WebSocket connected');
};
ws.onmessage = (event) => {
try {
if (typeof event.data === 'string') {
const message = JSON.parse(event.data);
handleWebSocketMessage(message);
}
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
updateWSStatus(false);
};
ws.onclose = () => {
updateWSStatus(false);
setTimeout(connectWebSocket, 3000);
};
}
function updateWSStatus(connected) {
const dot = document.getElementById('wsStatus');
const text = document.getElementById('wsStatusText');
if (connected) {
dot.classList.add('connected');
text.textContent = 'Connected';
} else {
dot.classList.remove('connected');
text.textContent = 'Disconnected';
}
}
function handleWebSocketMessage(message) {
const { type, data } = message;
if (type === 'message_created') {
messageCache.text.unshift(data);
renderMessages();
} else if (type === 'message_updated') {
const msg = messageCache.text.find(m => m.id === data.id);
if (msg) {
msg.edited_content = data.edited_content;
msg.edited_at = data.edited_at;
}
renderMessages();
} else if (type === 'message_deleted') {
messageCache.text = messageCache.text.filter(m => m.id !== data.id);
renderMessages();
} else if (type === 'attachment_uploaded') {
messageCache.image.unshift(data);
renderImages();
}
}
async function fetchMessages() {
try {
const params = new URLSearchParams();
if (selectedChannel) params.append('channel', selectedChannel);
params.append('type', 'text');
params.append('limit', '50');
const response = await fetch(`${API_BASE}/api/messages?${params}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
messageCache.text = result.data || [];
renderMessages();
} catch (error) {
showError(`Failed to fetch messages: ${error.message}`);
}
}
async function fetchImages() {
try {
const params = new URLSearchParams();
if (selectedChannel) params.append('channel', selectedChannel);
params.append('type', 'image');
params.append('limit', '50');
const response = await fetch(`${API_BASE}/api/messages?${params}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
messageCache.image = result.data || [];
renderImages();
} catch (error) {
showError(`Failed to fetch images: ${error.message}`);
}
}
function renderMessages() {
const list = document.getElementById('textList');
if (messageCache.text.length === 0) {
list.innerHTML = '<div class="empty">No messages</div>';
return;
}
list.innerHTML = messageCache.text
.map(msg => `
<div class="message-item">
<div class="message-header">
<div class="avatar">
${msg.avatar_url ? `<img src="${msg.avatar_url}" alt="${msg.username}">` : ''}
</div>
<div class="user-info">
<div class="username">${escapeHtml(msg.username)}</div>
<div class="timestamp">${new Date(msg.created_at).toLocaleString()}</div>
</div>
</div>
<div class="message-content">${escapeHtml(msg.content)}</div>
<div class="message-meta">
${msg.type === 'edited' ? '<span class="badge edited">Edited</span>' : ''}
${msg.type === 'deleted' ? '<span class="badge deleted">Deleted</span>' : ''}
${msg.edited_at ? `<span class="badge">Edited at ${new Date(msg.edited_at).toLocaleString()}</span>` : ''}
</div>
</div>
`)
.join('');
}
function renderImages() {
const grid = document.getElementById('imageGrid');
if (messageCache.image.length === 0) {
grid.innerHTML = '<div class="empty">No images</div>';
return;
}
grid.innerHTML = messageCache.image
.map(img => `
<div class="image-item">
<div class="image-preview">
${img.uploaded_url ? `<img src="${img.uploaded_url}" alt="${img.filename}">` : '<span>Uploading...</span>'}
</div>
<div class="image-info">
<div class="image-filename">${escapeHtml(img.filename)}</div>
<div class="image-meta">
<span>${(img.size / 1024).toFixed(1)}KB</span>
${img.uploaded_url ? `<a href="${img.uploaded_url}" target="_blank" class="image-url">View</a>` : '<span>Pending</span>'}
</div>
</div>
</div>
`)
.join('');
}
function showError(message) {
const errorDiv = document.getElementById('error');
errorDiv.textContent = message;
errorDiv.style.display = 'block';
setTimeout(() => {
errorDiv.style.display = 'none';
}, 5000);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const tabName = tab.dataset.tab;
document.getElementById(tabName).classList.add('active');
if (tabName === 'text') fetchMessages();
else if (tabName === 'image') fetchImages();
});
});
document.getElementById('channelFilter').addEventListener('change', (e) => {
selectedChannel = e.target.value;
fetchMessages();
fetchImages();
});
connectWebSocket();
fetchMessages();
</script>
</body>
</html>

View File

@@ -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);

View File

@@ -1 +1,101 @@
<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=/"></head><body></body></html> <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Discord Moderation Watcher</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/dashboard.css">
</head>
<body>
<main class="shell">
<section class="hero">
<div class="brand-card">
<div class="eyebrow"><span class="pulse"></span> Discord moderation watcher</div>
<h1>Voice. Text. One Watch Floor.</h1>
<p class="subtitle">Static client with legacy working voice bridge plus captured Discord messages, stickers, embeds, replies, and attachments.</p>
</div>
<div class="status-card">
<div class="status-row"><span class="status-label">WebSocket</span><span class="status-value"><span id="wsDot" class="dot"></span><span id="wsStatusText">Connecting</span></span></div>
<div class="status-row"><span class="status-label">Voice Link</span><span id="voiceStatusText" class="status-value">Not connected</span></div>
<div class="status-row"><span class="status-label">Active Tab</span><span id="activeTabLabel" class="status-value">Voice</span></div>
</div>
</section>
<nav class="tab-panel">
<div class="tabs"><button class="tab-btn active" data-tab="voice">Voice</button><button class="tab-btn" data-tab="text">Text</button></div>
<div class="filter-row"><span>Channel / Thread</span><select id="channelFilter"><option value="">Select channel</option></select></div>
</nav>
<div id="errorBox" class="error"></div>
<section id="voice" class="tab-content active">
<div class="voice-layout">
<div class="content-card">
<div class="card-title"><h2>Voice Control</h2><span class="mini">bridge</span></div>
<div class="field-group"><label for="guildSelect">Guild</label><select id="guildSelect"><option value="">Select guild</option></select></div>
<div class="field-group"><label for="channelSelect">Voice Channel</label><select id="channelSelect"><option value="">Select voice channel</option></select></div>
<div class="button-row"><button id="joinVoiceBtn" class="btn btn-success">Join</button><button id="disconnectVoiceBtn" class="btn btn-danger">Disconnect</button></div>
<div class="voice-status" id="voiceStatusNote">Idle</div>
</div>
<div class="content-card">
<div class="card-title"><h2>Live Audio</h2><span class="mini" id="listenStatus">Speaker Off</span></div>
<div style="display:grid;gap:12px;grid-template-columns:1fr 1fr;margin-bottom:14px"><button id="toggleBtn" class="btn btn-primary">Start Transmitting</button><button id="listenBtn" class="btn btn-success">Join Listen Channel</button></div>
<div class="visualizer" id="visualizer"></div>
</div>
</div>
<div class="content-card" style="margin-top:18px"><div class="card-title"><h2>Participants</h2><span class="mini">speaking now</span></div><div id="userList" class="participants"></div></div>
</section>
<section id="text" class="tab-content"><div class="content-card"><div class="card-title"><h2>Text Watch</h2><span class="mini">create / edit / delete</span></div><div id="textList" class="feed"><div class="empty">Select channel to view text captures</div></div></div></section>
</main>
<script>
const state = { socket: null, activeTab: 'voice', selectedChannel: new URLSearchParams(location.search).get('channel') || '', selectedGuild: new URLSearchParams(location.search).get('guild') || '', text: [], isStreaming: false, isListening: false, audioContextTransmit: null, audioContextListen: null, processor: null, userTimelines: new Map() };
const SAMPLE_RATE = 24000;
const CHANNELS = 1;
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); } }
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); } }
function appendEmpty(parent, message) { const empty = document.createElement('div'); empty.className = 'empty'; empty.textContent = message; parent.appendChild(empty); }
function appendBadge(parent, label, className) { const badge = document.createElement('span'); badge.className = `badge ${className}`; badge.textContent = label; parent.appendChild(badge); }
function parseMetadata(value) { if (!value) return {}; try { return JSON.parse(value); } catch { return {}; } }
async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.guildSelect, guilds, 'Select guild'); const guildId = state.selectedGuild || guilds[0]?.id || ''; if (guildId) { el.guildSelect.value = guildId; await loadChannels(guildId); } }
async function loadChannels(guildId) { const [voiceChannels, watchChannels] = await Promise.all([apiRequest(`/api/guilds/${guildId}/voice-channels`), 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}`)); }
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 = location.protocol === 'https:' ? 'wss:' : 'ws:'; state.socket = new WebSocket(`${protocol}//${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 (event.data instanceof ArrayBuffer) { handleIncomingPCM(event.data); return; } try { handleJsonEvent(event.data); } catch {} }; }
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) return appendEmpty(el.userList, 'No active speakers'); 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 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)'; card.append(head, text); appendMedia(card, metadata); 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.appendChild(badges); el.textList.appendChild(card); } }
function appendMedia(card, metadata) { const stickers = document.createElement('div'); stickers.className = 'sticker-strip'; for (const sticker of metadata.stickers || []) { const img = document.createElement('img'); img.className = 'sticker-img'; img.src = sticker.url; img.alt = sticker.name; stickers.appendChild(img); } if (stickers.childElementCount) card.appendChild(stickers); const embeds = document.createElement('div'); embeds.className = 'feed'; for (const embed of metadata.embeds || []) { const item = document.createElement('div'); item.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'; } item.appendChild(title); } if (embed.description) { const desc = document.createElement('div'); desc.className = 'embed-description'; desc.textContent = embed.description; item.appendChild(desc); } 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'; item.appendChild(img); } embeds.appendChild(item); } if (embeds.childElementCount) card.appendChild(embeds); const attachments = document.createElement('div'); attachments.className = 'attachment-strip'; for (const attachment of metadata.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)`; attachments.appendChild(link); } if (attachments.childElementCount) card.appendChild(attachments); }
function handleIncomingPCM(data) { if (!state.isListening || !state.audioContextListen) return; const headerView = new DataView(data, 0, 4); const userIdHash = headerView.getInt32(0, true); const audioData = data.slice(4); 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; source.start(userNextStartTime); userNextStartTime += audioBuffer.duration; state.userTimelines.set(userIdHash, userNextStartTime); }
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 = (event) => { if (!state.isStreaming || state.socket.readyState !== WebSocket.OPEN) return; const inputData = event.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 (error) { showError(`Microphone access denied: ${error.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() { if (!state.audioContextListen) state.audioContextListen = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE }); if (state.isListening) { state.audioContextListen.suspend(); el.listenBtn.textContent = 'Join Listen Channel'; el.listenStatus.textContent = 'Speaker Off'; state.isListening = false; } else { state.audioContextListen.resume(); el.listenBtn.textContent = 'Stop Listening'; el.listenStatus.textContent = 'Listening Live...'; state.isListening = true; } }
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(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); history.replaceState({}, '', url); await fetchText().catch((error) => showError(error.message)); });
connectWebSocket(); loadGuilds().then(refreshStatus).then(fetchText).catch((error) => showError(error.message)); setInterval(() => { if (state.activeTab === 'text') fetchText().catch(() => {}); }, 7000);
</script>
</body>
</html>

View File

@@ -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(/</g, "\\u003c");
}
function MessageCard({ message }: { message: MessageRecord }) {
const metadata = parseMetadata(message.metadata);
const content = message.edited_content || message.content || "(empty message)";
return (
<article className="event-card" data-message-id={message.id}>
<div className="event-head">
<div className="author">
<div className="avatar">
{message.avatar_url ? <img src={message.avatar_url} alt="" /> : null}
</div>
<div className="name">{message.username || message.user_id}</div>
</div>
<div className="time">{new Date(message.created_at).toLocaleString()}</div>
</div>
<div className="message-text">{content}</div>
{metadata.stickers?.length ? (
<div className="sticker-strip">
{metadata.stickers.map((sticker: any) => (
<img key={sticker.id} className="sticker-img" src={sticker.url} alt={sticker.name} />
))}
</div>
) : null}
{metadata.embeds?.length ? (
<div className="feed">
{metadata.embeds.map((embed: any, index: number) => (
<div key={index} className="embed-card">
{embed.title ? (
embed.url ? (
<a className="embed-title" href={embed.url} target="_blank" rel="noreferrer">{embed.title}</a>
) : (
<div className="embed-title">{embed.title}</div>
)
) : null}
{embed.description ? <div className="embed-description">{embed.description}</div> : null}
{embed.fields?.map((field: any, fieldIndex: number) => (
<div key={fieldIndex} className="embed-description">{field.name}: {field.value}</div>
))}
{embed.image || embed.thumbnail ? (
<img className="embed-image" src={embed.image || embed.thumbnail} alt={embed.title || "embed image"} />
) : null}
</div>
))}
</div>
) : null}
{metadata.attachments?.length ? (
<div className="attachment-strip">
{metadata.attachments.map((attachment: any) => (
<a key={attachment.id} className="attachment-chip" href={attachment.url} target="_blank" rel="noreferrer">
{attachment.name} ({(attachment.size / 1024).toFixed(1)}KB)
</a>
))}
</div>
) : null}
<div className="badges">
{metadata.reference?.messageId ? <span className="badge">reply</span> : null}
{message.thread_id ? (
<span className="badge">{metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : "thread"}</span>
) : null}
{message.edited_at ? <span className="badge edit">edited</span> : null}
{message.deleted_at ? <span className="badge delete">deleted</span> : null}
</div>
</article>
);
}
function DashboardPage(props: DashboardProps) {
return (
<main className="shell">
<section className="hero">
<div className="brand-card">
<div className="eyebrow"><span className="pulse" /> Discord moderation command center</div>
<h1>Voice. Text. One Watch Floor.</h1>
<p className="subtitle">Single-page watcher for live voice bridge and captured Discord messages, including stickers, embeds, replies, and uploaded image evidence inline.</p>
</div>
<div className="status-card">
<div className="status-row"><span className="status-label">WebSocket</span><span className="status-value"><span id="wsDot" className="dot" /><span id="wsStatusText">Connecting</span></span></div>
<div className="status-row"><span className="status-label">Voice Link</span><span id="voiceStatusText" className="status-value">{props.status.connected ? props.status.activeChannelName || "Connected" : "Not connected"}</span></div>
<div className="status-row"><span className="status-label">Active Tab</span><span id="activeTabLabel" className="status-value">Voice</span></div>
</div>
</section>
<nav className="tab-panel">
<div className="tabs">
<button className="tab-btn active" data-tab="voice">Voice</button>
<button className="tab-btn" data-tab="text">Text</button>
</div>
<div className="filter-row">
<span>Channel / Thread</span>
<select id="channelFilter" defaultValue={props.selectedChannelId}>
<option value="">Select channel</option>
{props.watchChannels.map((channel) => (
<option key={channel.id} value={channel.id}>{channel.name}</option>
))}
</select>
</div>
</nav>
<div id="errorBox" className="error" />
<section id="voice" className="tab-content active">
<div className="voice-layout">
<div className="content-card">
<div className="card-title"><h2>Voice Control</h2><span className="mini">bridge</span></div>
<div className="field-group">
<label htmlFor="guildSelect">Guild</label>
<select id="guildSelect" defaultValue={props.selectedGuildId}>
<option value="">Select guild</option>
{props.guilds.map((guild) => <option key={guild.id} value={guild.id}>{guild.name}</option>)}
</select>
</div>
<div className="field-group">
<label htmlFor="channelSelect">Voice Channel</label>
<select id="channelSelect">
<option value="">Select voice channel</option>
{props.voiceChannels.map((channel) => <option key={channel.id} value={channel.id}>{channel.name}</option>)}
</select>
</div>
<div className="button-row">
<button id="joinVoiceBtn" className="btn btn-success">Join</button>
<button id="disconnectVoiceBtn" className="btn btn-danger">Disconnect</button>
</div>
<div className="voice-status" id="voiceStatusNote">{props.status.connected ? `Connected to ${props.status.activeChannelName}` : "Idle"}</div>
</div>
<div className="content-card">
<div className="card-title"><h2>Live Audio</h2><span className="mini" id="listenStatus">speaker off</span></div>
<div style={{ display: "grid", gap: 12, gridTemplateColumns: "1fr 1fr", marginBottom: 14 }}>
<button id="toggleBtn" className="btn btn-primary">Start Transmitting</button>
<button id="listenBtn" className="btn btn-success">Join Listen Channel</button>
</div>
<div className="visualizer" id="visualizer" />
</div>
</div>
<div className="content-card" style={{ marginTop: 18 }}>
<div className="card-title"><h2>Participants</h2><span className="mini">speaking now</span></div>
<div id="userList" className="participants" />
</div>
</section>
<section id="text" className="tab-content">
<div className="content-card">
<div className="card-title"><h2>Text Watch</h2><span className="mini">create / edit / delete</span></div>
<div id="textList" className="feed">
{!props.selectedChannelId ? <div className="empty">Select channel to view text captures</div> : null}
{props.selectedChannelId && props.messages.length === 0 ? <div className="empty">No text captures yet</div> : null}
{props.messages.map((message) => <MessageCard key={message.id} message={message} />)}
</div>
</div>
</section>
</main>
);
}
export function renderDashboardPage(props: DashboardProps): string {
const app = renderToString(<DashboardPage {...props} />);
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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Discord Moderation Watcher</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=JetBrains+Mono:wght@400;600;700&family=Manrope:wght@500;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/dashboard.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/15.1.22/Tone.js"></script>
</head>
<body>
<div id="root">${app}</div>
<script id="__DASHBOARD_DATA__" type="application/json">${bootstrap}</script>
<script src="/dashboard.js" defer></script>
</body>
</html>`;
}

View File

@@ -10,7 +10,6 @@ import { AppError } from "./errors";
import { createChildLogger, logger } from "./logger"; import { createChildLogger, logger } from "./logger";
import { getMetrics, uptimeGauge } from "./metrics"; import { getMetrics, uptimeGauge } from "./metrics";
import { discordPlayer } from "./player"; import { discordPlayer } from "./player";
import { renderDashboardPage } from "./web/dashboardPage";
import type { VoiceController } from "./voiceController"; import type { VoiceController } from "./voiceController";
import { getDatabase } from "./muxer-queue"; import { getDatabase } from "./muxer-queue";
import { getMessagesByChannel, getAttachmentsByChannel } from "./moderation/messageStore"; import { getMessagesByChannel, getAttachmentsByChannel } from "./moderation/messageStore";
@@ -71,41 +70,12 @@ export function startWebserver(
app.use(pinoHttp({ logger })); app.use(pinoHttp({ logger }));
app.use(express.json()); 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.use(express.static(path.join(__dirname, "../public")));
app.get("/", (_req, res) => {
res.sendFile(path.join(__dirname, "../public/index.html"));
});
// Health check endpoint // Health check endpoint
app.get("/health", (_req, res) => { app.get("/health", (_req, res) => {
res.json({ res.json({