Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a02d1fb7c0 | ||
|
|
0bdab3b446 | ||
|
|
cbfb99f755 | ||
|
|
bac42b1d53 | ||
|
|
ff83e34b77 | ||
|
|
94dc460fc7 |
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
**Discord Moderation Watcher Bot** — A comprehensive monitoring bot that captures voice, text messages, and images from Discord servers. Records audio from voice channels, captures all text messages (new/edited/deleted) from channels and threads, and uploads attachments to external storage. All data stored in SQLite with real-time dashboard.
|
**Discord Moderation Watcher Bot** — A comprehensive monitoring bot that captures voice, text messages, and images from Discord servers. Records audio from voice channels, captures all text messages (new/edited/deleted) from channels and threads, and uploads attachments to external storage. All data stored in SQLite with real-time dashboard.
|
||||||
|
|
||||||
Built with **Bun** + **discord.js-selfbot-v13** + **@discordjs/voice** + **Express** + **WebSocket**.
|
Built with **Node.js/pnpm** + **discord.js-selfbot-v13** + **@discordjs/voice** + **Express** + **WebSocket**.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -73,28 +73,28 @@ attachments (SQLite):
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
bun install
|
pnpm install
|
||||||
|
|
||||||
# Development (auto-restart on file changes)
|
# Development (auto-restart on file changes)
|
||||||
bun run dev
|
pnpm run dev
|
||||||
|
|
||||||
# Production
|
# Production
|
||||||
bun run start
|
pnpm run start
|
||||||
|
|
||||||
# Type checking
|
# Type checking
|
||||||
bun run typecheck
|
pnpm run typecheck
|
||||||
|
|
||||||
# Linting (Biome)
|
# Linting (Biome)
|
||||||
bun run lint
|
pnpm run lint
|
||||||
|
|
||||||
# Format code (Biome)
|
# Format code (Biome)
|
||||||
bun run format
|
pnpm run format
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
bun run test
|
pnpm run test
|
||||||
|
|
||||||
# Build TypeScript
|
# Build TypeScript
|
||||||
bun run build
|
pnpm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -136,7 +136,7 @@ All config via `.env` (see `.env.example`). Key variables:
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Tests use **Vitest** in `tests/` directory. Run with `bun run test`.
|
Tests use **Vitest** in `tests/` directory. Run with `pnpm run test`.
|
||||||
|
|
||||||
**Test Coverage:**
|
**Test Coverage:**
|
||||||
- `tests/moderation/messageStore.test.ts` — Message store CRUD operations
|
- `tests/moderation/messageStore.test.ts` — Message store CRUD operations
|
||||||
@@ -290,7 +290,7 @@ Handles SIGINT/SIGTERM/uncaughtException/unhandledRejection:
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Bot uses selfbot variant (user account) rather than standard bot token — check Discord ToS
|
- Bot uses selfbot variant (user account) rather than standard bot token — check Discord ToS
|
||||||
- Opus decoding disabled on Bun without native opus to avoid crashes
|
- Opus decoding requires native `@discordjs/opus` under Node.js
|
||||||
- OGG segments include metadata JSON for each segment (user info, timestamps, duration)
|
- OGG segments include metadata JSON for each segment (user info, timestamps, duration)
|
||||||
- WebSocket broadcasts PCM in real-time; browser can transmit audio back to Discord
|
- WebSocket broadcasts PCM in real-time; browser can transmit audio back to Discord
|
||||||
- Graceful shutdown ensures clean disconnection and resource cleanup
|
- Graceful shutdown ensures clean disconnection and resource cleanup
|
||||||
@@ -327,7 +327,7 @@ Handles SIGINT/SIGTERM/uncaughtException/unhandledRejection:
|
|||||||
|
|
||||||
- **Recorder subsystem** (`src/recorder/`):
|
- **Recorder subsystem** (`src/recorder/`):
|
||||||
- `audioStream.ts` — Subscribes to Discord audio receiver, emits Opus packets
|
- `audioStream.ts` — Subscribes to Discord audio receiver, emits Opus packets
|
||||||
- `decoder.ts` — Opus decoder with runtime checks (Bun vs Node), cooldown/rotation logic for web PCM broadcast
|
- `decoder.ts` — Opus decoder with runtime checks, cooldown/rotation logic for web PCM broadcast
|
||||||
- `segment.ts` — Manages OGG file rotation (5s default segments per user)
|
- `segment.ts` — Manages OGG file rotation (5s default segments per user)
|
||||||
- `metadata.ts` — Collects user/role info, creates segment metadata JSON
|
- `metadata.ts` — Collects user/role info, creates segment metadata JSON
|
||||||
|
|
||||||
@@ -363,28 +363,28 @@ Each segment is 5s (configurable). Metadata JSON includes user info, roles, time
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
bun install
|
pnpm install
|
||||||
|
|
||||||
# Development (auto-restart on file changes)
|
# Development (auto-restart on file changes)
|
||||||
bun run dev
|
pnpm run dev
|
||||||
|
|
||||||
# Production
|
# Production
|
||||||
bun run start
|
pnpm run start
|
||||||
|
|
||||||
# Type checking
|
# Type checking
|
||||||
bun run typecheck
|
pnpm run typecheck
|
||||||
|
|
||||||
# Linting (Biome)
|
# Linting (Biome)
|
||||||
bun run lint
|
pnpm run lint
|
||||||
|
|
||||||
# Format code (Biome)
|
# Format code (Biome)
|
||||||
bun run format
|
pnpm run format
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
bun run test
|
pnpm run test
|
||||||
|
|
||||||
# Build TypeScript
|
# Build TypeScript
|
||||||
bun run build
|
pnpm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -404,9 +404,9 @@ All config via `.env` (see `.env.example`). Key variables:
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Tests use **Vitest** in `tests/` directory. Run with `bun run test`.
|
Tests use **Vitest** in `tests/` directory. Run with `pnpm run test`.
|
||||||
|
|
||||||
Example: `tests/decoder.test.ts` tests Opus decoder runtime detection (Bun vs Node, native opus availability).
|
Example: `tests/decoder.test.ts` tests Opus decoder runtime detection and native opus availability.
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
@@ -507,7 +507,7 @@ These will likely require:
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Bot uses selfbot variant (user account) rather than standard bot token — check Discord ToS
|
- Bot uses selfbot variant (user account) rather than standard bot token — check Discord ToS
|
||||||
- Opus decoding disabled on Bun without native opus to avoid crashes
|
- Opus decoding requires native `@discordjs/opus` under Node.js
|
||||||
- OGG segments include metadata JSON for each segment (user info, timestamps, duration)
|
- OGG segments include metadata JSON for each segment (user info, timestamps, duration)
|
||||||
- WebSocket broadcasts PCM in real-time; browser can transmit audio back to Discord
|
- WebSocket broadcasts PCM in real-time; browser can transmit audio back to Discord
|
||||||
- Graceful shutdown ensures clean disconnection and resource cleanup
|
- Graceful shutdown ensures clean disconnection and resource cleanup
|
||||||
|
|||||||
+5
-9
@@ -3,9 +3,10 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Discord bot that joins a voice channel and records audio",
|
"description": "Discord bot that joins a voice channel and records audio",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
"packageManager": "pnpm@10.25.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"start": "bun src/index.ts",
|
"start": "tsx src/index.ts",
|
||||||
"build": "tsc --outDir dist",
|
"build": "tsc --outDir dist",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "biome check --diagnostic-level=error .",
|
"lint": "biome check --diagnostic-level=error .",
|
||||||
@@ -24,17 +25,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,11 +40,10 @@
|
|||||||
"@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/node": "^24.10.1",
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"bun-types": "latest",
|
|
||||||
"pino-pretty": "^10.3.1",
|
"pino-pretty": "^10.3.1",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
"vitest": "latest"
|
"vitest": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3626
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@discordjs/opus'
|
||||||
|
- better-sqlite3
|
||||||
|
- esbuild
|
||||||
@@ -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);
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,468 +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,
|
|
||||||
mic: null,
|
|
||||||
analyser: null,
|
|
||||||
meter: null,
|
|
||||||
synth: null,
|
|
||||||
nextStartTime: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SAMPLE_RATE = 24000;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (state.isListening) 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 {
|
|
||||||
await Tone.start();
|
|
||||||
state.mic = new Tone.UserMedia();
|
|
||||||
state.analyser = new Tone.Analyser('waveform');
|
|
||||||
state.meter = new Tone.Meter();
|
|
||||||
|
|
||||||
state.mic.connect(state.analyser);
|
|
||||||
state.mic.connect(state.meter);
|
|
||||||
|
|
||||||
await state.mic.open();
|
|
||||||
|
|
||||||
const analyzeInterval = setInterval(() => {
|
|
||||||
if (!state.isStreaming) {
|
|
||||||
clearInterval(analyzeInterval);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const level = state.meter.getValue();
|
|
||||||
updateVisualizer(Math.max(0, level + 100) / 100);
|
|
||||||
|
|
||||||
const waveform = state.analyser.getValue();
|
|
||||||
if (waveform && state.socket?.readyState === WebSocket.OPEN) {
|
|
||||||
const pcm = new Int16Array(waveform.length);
|
|
||||||
for (let i = 0; i < waveform.length; i++) {
|
|
||||||
pcm[i] = Math.max(-1, Math.min(1, waveform[i])) * 32767;
|
|
||||||
}
|
|
||||||
state.socket.send(pcm.buffer);
|
|
||||||
}
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
state.isStreaming = true;
|
|
||||||
el.toggleBtn.textContent = 'Stop Transmitting';
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Microphone error: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopStreaming() {
|
|
||||||
state.isStreaming = false;
|
|
||||||
state.mic?.close();
|
|
||||||
state.mic = null;
|
|
||||||
state.analyser = null;
|
|
||||||
state.meter = null;
|
|
||||||
el.toggleBtn.textContent = 'Start Transmitting';
|
|
||||||
updateVisualizer(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleListen() {
|
|
||||||
state.isListening = !state.isListening;
|
|
||||||
if (state.isListening) {
|
|
||||||
el.listenBtn.textContent = 'Leave Listen Channel';
|
|
||||||
el.listenStatus.textContent = 'speaker on';
|
|
||||||
} else {
|
|
||||||
el.listenBtn.textContent = 'Join Listen Channel';
|
|
||||||
el.listenStatus.textContent = 'speaker off';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function playPcm(arrayBuffer) {
|
|
||||||
if (!state.isListening) return;
|
|
||||||
const bytes = new Uint8Array(arrayBuffer);
|
|
||||||
if (bytes.byteLength <= 4) return;
|
|
||||||
const pcm = new Int16Array(bytes.buffer, bytes.byteOffset + 4, (bytes.byteLength - 4) / 2);
|
|
||||||
|
|
||||||
const audioBuffer = Tone.context.createBuffer(1, pcm.length, 24000);
|
|
||||||
const channel = audioBuffer.getChannelData(0);
|
|
||||||
for (let i = 0; i < pcm.length; i++) channel[i] = pcm[i] / 32768;
|
|
||||||
|
|
||||||
const source = Tone.context.createBufferSource();
|
|
||||||
source.buffer = audioBuffer;
|
|
||||||
source.connect(Tone.context.destination);
|
|
||||||
source.start(Tone.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
+101
-1
File diff suppressed because one or more lines are too long
+11
-1
@@ -15,10 +15,15 @@ import { getDatabase } from "./muxer-queue";
|
|||||||
const logger = createChildLogger("bot");
|
const logger = createChildLogger("bot");
|
||||||
|
|
||||||
const token = config.DISCORD_TOKEN;
|
const token = config.DISCORD_TOKEN;
|
||||||
|
logger.info({ hasToken: token.length > 0, tokenLength: token.length }, "Config loaded");
|
||||||
|
|
||||||
|
logger.info("Creating Discord client");
|
||||||
const client = new Client();
|
const client = new Client();
|
||||||
const voiceController = new VoiceController(client);
|
const voiceController = new VoiceController(client);
|
||||||
|
|
||||||
|
logger.info("Opening database");
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
logger.info("Database ready");
|
||||||
|
|
||||||
let isShuttingDown = false;
|
let isShuttingDown = false;
|
||||||
|
|
||||||
@@ -84,5 +89,10 @@ process.on("unhandledRejection", (reason, promise) => {
|
|||||||
gracefulShutdown("unhandledRejection");
|
gracefulShutdown("unhandledRejection");
|
||||||
});
|
});
|
||||||
|
|
||||||
client.login(token);
|
logger.info("Calling Discord client.login");
|
||||||
|
client.login(token).then(() => {
|
||||||
|
logger.info("Discord client.login resolved");
|
||||||
|
}).catch((error) => {
|
||||||
|
logger.error({ error }, "Discord client.login failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ const isDev = process.env.NODE_ENV !== "production";
|
|||||||
|
|
||||||
export const logger = pino({
|
export const logger = pino({
|
||||||
level: process.env.LOG_LEVEL || (isDev ? "debug" : "info"),
|
level: process.env.LOG_LEVEL || (isDev ? "debug" : "info"),
|
||||||
|
serializers: {
|
||||||
|
error: pino.stdSerializers.err,
|
||||||
|
err: pino.stdSerializers.err,
|
||||||
|
reason: (value) => value instanceof Error ? pino.stdSerializers.err(value) : value,
|
||||||
|
},
|
||||||
transport: isDev
|
transport: isDev
|
||||||
? {
|
? {
|
||||||
target: "pino-pretty",
|
target: "pino-pretty",
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { Database } from "bun:sqlite";
|
import Database from "better-sqlite3";
|
||||||
import { createChildLogger } from "./logger";
|
import { createChildLogger } from "./logger";
|
||||||
|
|
||||||
const logger = createChildLogger("muxer-queue");
|
const logger = createChildLogger("muxer-queue");
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
StreamType,
|
StreamType,
|
||||||
VoiceConnection,
|
VoiceConnection,
|
||||||
} from "@discordjs/voice";
|
} from "@discordjs/voice";
|
||||||
import prism from "prism-media";
|
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
|
|
||||||
export class DiscordPlayer {
|
export class DiscordPlayer {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import prism from "prism-media";
|
import * as prism from "prism-media";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import prism from "prism-media";
|
import * as prism from "prism-media";
|
||||||
import type { SegmentState } from "../types";
|
import type { SegmentState } from "../types";
|
||||||
|
|
||||||
export function buildSegmentPaths(
|
export function buildSegmentPaths(
|
||||||
|
|||||||
@@ -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>`;
|
|
||||||
}
|
|
||||||
+5
-35
@@ -4,13 +4,12 @@ import helmet from "helmet";
|
|||||||
import http from "http";
|
import http from "http";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import pinoHttp from "pino-http";
|
import pinoHttp from "pino-http";
|
||||||
import prism from "prism-media";
|
import * as prism from "prism-media";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import { AppError } from "./errors";
|
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({
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": ["bun-types"],
|
"types": ["node"],
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user