Compare commits

..

39 Commits

Author SHA1 Message Date
MythEclipse
8584030633 feat: automate .env deployment via GitHub Secrets and local file sync 2026-05-17 21:24:54 +07:00
MythEclipse
f35710db3f feat: add Docker configuration and automated deployment workflows for VPS orchestration 2026-05-17 21:23:50 +07:00
MythEclipse
0c7930bd01 chore: update streaming default quality settings to 1080p60 at 8mbps with ultrafast preset 2026-05-17 20:47:41 +07:00
MythEclipse
e22e620bae feat: add streaming demuxer tests and implement dual-stream output piping in streaming service 2026-05-17 20:44:13 +07:00
MythEclipse
eda32720c8 a 2026-05-17 19:39:46 +07:00
MythEclipse
c0f66c78a3 chore: update subproject commit to indicate dirty state 2026-05-17 18:24:17 +07:00
MythEclipse
b8a6f40b1b feat: enhance database initialization for test isolation and add transcoder metrics 2026-05-17 18:24:10 +07:00
MythEclipse
4931e6d1ca test: add unit tests for playTranscodedPreparedStream and transcoder functionality 2026-05-17 17:54:39 +07:00
MythEclipse
a3e6c4695a chore: remove test environment configuration file 2026-05-17 17:35:37 +07:00
MythEclipse
6de5342703 feat: refactor screen share controller to use Streamer for session management and simplify stream handling 2026-05-17 05:15:38 +07:00
MythEclipse
5a926dbd17 refactor: remove Discord-video-stream submodule and integrate streaming functionality 2026-05-17 05:10:46 +07:00
MythEclipse
7985efbef6 docs: add internal streamer replacement design 2026-05-17 05:00:04 +07:00
MythEclipse
71889ab689 chore: update Discord-video-stream subproject to latest commit 2026-05-17 04:52:20 +07:00
MythEclipse
518577d79d feat: enhance screen share controller with Streamer integration and voice channel management 2026-05-17 01:01:40 +07:00
MythEclipse
d04093ec6e feat: add admin authentication to media and voice routes for secure access 2026-05-17 00:39:29 +07:00
MythEclipse
05feb697f0 feat: implement admin authentication overlay and API integration for secure access to voice and media controls 2026-05-17 00:20:29 +07:00
MythEclipse
a5b5ccf5b0 refactor: enhance message ID handling in parseModerationResponse for precision loss and duplicates 2026-05-16 23:47:50 +07:00
MythEclipse
99ec528a03 refactor: remove unused getThreads function and related code from voice API and controller 2026-05-16 23:34:07 +07:00
MythEclipse
7dedac2094 refactor: clean up code structure and improve error handling in media controllers 2026-05-16 23:11:46 +07:00
MythEclipse
9b211f05cf refactor: change logger level from info to debug for voice activity detection 2026-05-16 21:56:14 +07:00
MythEclipse
4825dc6d4d feat: add mock-crc module for ESM compatibility 2026-05-16 21:52:13 +07:00
MythEclipse
9ad7d16a17 feat: expand activeTab options in SharedUIState to include messages, media, and review 2026-05-16 21:42:28 +07:00
MythEclipse
62d131cf14 feat: implement local audio streaming with controls in voice components 2026-05-16 21:08:39 +07:00
MythEclipse
82025a19b2 feat: migrate and redesign dashboard to modern React
- Full rewrite of legacy vanilla JS UI into React SPA
- Implement modern design system using Tailwind CSS and shadcn/ui primitives
- Create typed API modules and hooks for voice, media, and moderation
- Add new features: separated Music and Screen Share panels, Image Grid
- Implement unified WebSocket hook for real-time state and PCM audio
- Improve visualizer with smooth CSS transitions and live state sync
- Add __dirname polyfill for ES module compatibility
- Ensure responsive layout for mobile and desktop

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 19:17:34 +07:00
MythEclipse
3c7d722973 feat: separate music and screen share UI in media player
- Add mode selector buttons (Music / Screen Share)
- Create separate input panels for music and screen share
- Add screenSourceInput and screen share queue/skip/stop buttons
- Update queueMedia to include mode parameter (music/screen)
- Add queueScreen function for screen share mode
- Toggle between panels based on selected mode

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 18:22:09 +07:00
MythEclipse
70931576dc fix: add __dirname polyfill for ES modules
- Import fileURLToPath from node:url
- Define __dirname using import.meta.url for ES module compatibility
- Fixes ReferenceError when serving static files in production

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 18:18:39 +07:00
MythEclipse
8e1f5adaa4 feat: implement session-level recording with self/bot audio filtering
- Add RecordingSession tracking for guild-level audio mixing
- Filter out self user and bot users before subscribing to audio streams
- Register segments with session metadata for post-processing
- Finalize recording sessions on connection destroy or explicit stop
- Update test mocks to properly simulate stream chain and constructors
- Remove mock-crc import (no longer needed with current dependencies)
- Add 'type: module' to package.json for ES module support

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 18:15:18 +07:00
MythEclipse
2744e7035b feat: implement full session recording with muxing support
- Add session recording metadata and mux filter builder in src/recorder/sessionRecording.ts.
- Update SegmentMetadata to include recordingSessionId in src/types.ts and src/recorder/metadata.ts.
- Modify recorder lifecycle to track sessions, register segments, and finalize recordings on stop.
- Create tests for session recording functionality in tests/recorder/sessionRecording.test.ts and tests/recorder/metadata.test.ts.
- Document session recording design and implementation plan in docs/superpowers/specs/2026-05-16-session-full-recording-design.md and docs/superpowers/plans/2026-05-16-session-full-recording.md.
2026-05-16 17:59:17 +07:00
MythEclipse
8b33af8286 feat: prevent recording of audio from bot users 2026-05-16 15:52:10 +07:00
MythEclipse
d50ce8698f feat: implement media echo fix and YouTube screenshare design
- Introduced a new `ScreenShareController` to manage YouTube screenshare functionality.
- Updated `DiscordPlayer` to track ownership of audio streams, preventing conflicts between music playback and screenshare.
- Added error handling for various states including voice connection checks and media busy states.
- Created unit tests for `ScreenShareController` and `DiscordPlayer` ownership rules to ensure correct functionality.
- Added documentation for the new media echo fix and screenshare design.
2026-05-16 15:48:28 +07:00
MythEclipse
e32e092596 feat: enhance media handling and audio processing logic 2026-05-15 22:23:29 +07:00
MythEclipse
6ac4a5c11a feat: add installation script for yt-dlp and update package.json 2026-05-15 21:40:20 +07:00
MythEclipse
119258c2b0 feat: add additional built dependencies for media processing 2026-05-15 20:24:03 +07:00
MythEclipse
5abe5cc39f feat: update media input guidance
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:49:01 +07:00
MythEclipse
c954cc0406 feat: resolve youtube search and spotify media
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:46:43 +07:00
MythEclipse
95ea0cee75 feat: add play-dl search resolver
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:44:42 +07:00
MythEclipse
2e30a063d2 feat: add yt-dlp media helper
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:43:06 +07:00
MythEclipse
6aeabc690f feat: prepare media resolver source kinds
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:11:23 +07:00
MythEclipse
73901231c3 feat: update backlog sync logic to queue requests and adjust response structure 2026-05-15 18:53:12 +07:00
130 changed files with 9738 additions and 2380 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
dist
.env
.env.test
.git
.github
recordings
*.db
*.db-shm
*.db-wal
test_out.nut

View File

@@ -1,30 +0,0 @@
DISCORD_TOKEN=test_token_for_testing
RECORDINGS_DIR=./recordings
RECORDING_SEGMENT_MS=5000
VERBOSE=false
DECODER_ROTATE_MS=5000
DECODER_COOLDOWN_MS=30000
AUDIO_STREAM_SILENCE_DURATION_MS=3000
PACKET_FILTER_MIN_SIZE=8
OPUS_FRAME_SIZE=960
AUDIO_SAMPLE_RATE=48000
AUDIO_CHANNELS=2
AVATAR_SIZE=64
WEBSERVER_PORT=3000
VOICE_CONNECTION_TIMEOUT_MS=15000
RECONNECT_TIMEOUT_MS=5000
LOG_LEVEL=info
NODE_ENV=test
MONITOR_GUILD_ID=test_guild_id
PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
ATTACHMENT_MAX_SIZE_MB=100
ATTACHMENT_RETRY_ATTEMPTS=3
BACKLOG_SYNC_HOURS=24
BACKLOG_SYNC_BATCH_SIZE=100
AI_ANALYSIS_ENABLED=false
AI_LLM_API_KEY=test_key
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
AI_LLM_MODEL=free
AI_ANALYSIS_TIMEOUT_MS=30000
DATABASE_TYPE=sqlite

35
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Deploy to VPS
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
env:
ENV_FILE: ${{ secrets.ENV_FILE }}
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USERNAME }}
key: ${{ secrets.VPS_SSH_KEY }}
envs: ENV_FILE
script: |
cd /opt/imphenbot || exit
# Pull latest changes
git pull origin main
# Write environment variables from GitHub Secrets
echo "$ENV_FILE" > .env
# Build and restart containers
docker-compose up -d --build

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ dist/
public/app/ public/app/
.muxer-queue.** .muxer-queue.**
.claude/ .claude/
.env.test

4
.gitmodules vendored
View File

@@ -1,6 +1,4 @@
[submodule "vendor/discord.js-selfbot-v13"] [submodule "vendor/discord.js-selfbot-v13"]
path = vendor/discord.js-selfbot-v13 path = vendor/discord.js-selfbot-v13
url = ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git url = ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
[submodule "vendor/Discord-video-stream"]
path = vendor/Discord-video-stream
url = ssh://git@43.134.105.109:22222/exceed/Discord-video-stream.git

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
FROM node:22-bookworm-slim
# Install dependencies required by node-canvas, ffmpeg, and yt-dlp
RUN apt-get update && apt-get install -y \
ffmpeg \
python3 \
curl \
git \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Install yt-dlp
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
chmod a+rx /usr/local/bin/yt-dlp
# Enable pnpm
RUN corepack enable
WORKDIR /app
# Install dependencies first for better caching
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml* ./
RUN pnpm install --frozen-lockfile
# Copy the rest of the application
COPY . .
# Build step if required (e.g. build:web)
RUN pnpm run build:web || true
# Set node environment
ENV NODE_ENV=production
# Start the application
CMD ["pnpm", "run", "start"]

View File

@@ -8,7 +8,8 @@ Stack utama: Node.js, pnpm, TypeScript, `discord.js-selfbot-v13`, `@discordjs/vo
- Node.js versi modern yang kompatibel dengan TypeScript dan Vite. - Node.js versi modern yang kompatibel dengan TypeScript dan Vite.
- pnpm 10.x. Repo ini dipin ke `pnpm@10.25.0`. - pnpm 10.x. Repo ini dipin ke `pnpm@10.25.0`.
- FFmpeg tersedia di `PATH` untuk proses muxing audio. - FFmpeg tersedia di `PATH` untuk proses muxing audio dan playback media.
- `yt-dlp` tersedia di `PATH` untuk resolve audio YouTube, search result YouTube, dan Spotify track.
- Native audio dependencies dapat dibuild di mesin lokal (`@discordjs/opus`, `better-sqlite3`, `sodium-native`). - Native audio dependencies dapat dibuild di mesin lokal (`@discordjs/opus`, `better-sqlite3`, `sodium-native`).
Install FFmpeg: Install FFmpeg:
@@ -21,6 +22,14 @@ sudo apt install ffmpeg
sudo pacman -S ffmpeg sudo pacman -S ffmpeg
``` ```
Install `yt-dlp`:
```bash
pnpm run install:yt-dlp
```
Script installer akan memakai package manager yang tersedia (`pacman`, `apt-get`, `dnf`, `brew`) atau fallback ke `pipx`/`pip`.
## Setup ## Setup
```bash ```bash
@@ -76,6 +85,9 @@ pnpm run test
# Build frontend + TypeScript # Build frontend + TypeScript
pnpm run build pnpm run build
# Install external yt-dlp CLI for YouTube/search/Spotify track playback
pnpm run install:yt-dlp
``` ```
## Database ## Database
@@ -104,7 +116,8 @@ pnpm run db:studio
- Attachment capture dan upload ke endpoint Picser. - Attachment capture dan upload ke endpoint Picser.
- SQLite/PostgreSQL via Drizzle ORM. - SQLite/PostgreSQL via Drizzle ORM.
- REST API dan WebSocket untuk dashboard. - REST API dan WebSocket untuk dashboard.
- Dashboard React untuk pesan, gambar, voice, dan moderation review. - Dashboard React untuk pesan, gambar, voice, media playback, dan moderation review.
- Media playback dari direct URL, file lokal, YouTube URL, search terms, dan Spotify track URL.
- Metrics Prometheus di endpoint server. - Metrics Prometheus di endpoint server.
- Retry dengan backoff untuk operasi eksternal. - Retry dengan backoff untuk operasi eksternal.
- AI moderation analysis opsional via konfigurasi `AI_*`. - AI moderation analysis opsional via konfigurasi `AI_*`.

20
components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "frontend/src/styles.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "frontend/src/components",
"utils": "frontend/src/lib/utils",
"ui": "frontend/src/components/ui",
"lib": "frontend/src/lib",
"hooks": "frontend/src/hooks"
}
}

60
debug-screen.ts Normal file
View File

@@ -0,0 +1,60 @@
import type { ChildProcess } from "node:child_process";
import dotenv from "dotenv";
import { createYtDlp } from "./src/media/ytdlp.js";
import { prepareStream } from "./src/streaming/index.js";
dotenv.config();
async function test() {
const ytdlp = createYtDlp();
const url = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"; // Small video
console.log("Getting direct video url...");
const directUrl = await ytdlp.getDirectVideoUrl(url);
console.log("Direct URL:", directUrl);
console.log("Preparing stream...");
const { command, output } = prepareStream(directUrl, {
logLevel: "debug",
customInputOptions: [
"-headers",
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.3\r\nConnection: keep-alive\r\n",
],
});
const ffmpeg = command as ChildProcess;
ffmpeg.stderr?.on("data", (data: Buffer) => {
console.log("FFMPEG STDERR:", data.toString());
});
let bytesRead = 0;
output.on("data", (chunk: Buffer) => {
bytesRead += chunk.length;
console.log("Stream bytes:", bytesRead);
if (bytesRead > 1024 * 1024) {
ffmpeg.kill("SIGTERM");
}
});
try {
await new Promise<void>((resolve, reject) => {
ffmpeg.on("exit", (code) => {
if (code === 0 || code === null) {
resolve();
return;
}
reject(new Error(`ffmpeg exited with code ${code}`));
});
ffmpeg.on("error", reject);
});
} catch (error: unknown) {
console.error(
"Debug stream failed:",
error instanceof Error ? error.message : String(error),
);
}
process.exit(0);
}
test();

45
deploy.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/bin/bash
# Configuration for CLI deployment
VPS_HOST="45.127.35.244"
VPS_USER="root"
# Find first available private key in ~/.ssh or use specific one if you want to hardcode
SSH_KEY_PATH=$(find ~/.ssh -name "id_rsa" -o -name "id_ed25519" | head -n 1)
echo "🚀 Starting CLI deployment to $VPS_USER@$VPS_HOST..."
if [ -z "$SSH_KEY_PATH" ]; then
echo "⚠️ No SSH key found in ~/.ssh. Falling back to default SSH behavior."
SSH_CMD="ssh -o StrictHostKeyChecking=no $VPS_USER@$VPS_HOST"
RSYNC_CMD="rsync -avz --exclude-from='.dockerignore' -e 'ssh -o StrictHostKeyChecking=no'"
else
echo "🔑 Using SSH key: $SSH_KEY_PATH"
SSH_CMD="ssh -i $SSH_KEY_PATH -o StrictHostKeyChecking=no $VPS_USER@$VPS_HOST"
RSYNC_CMD="rsync -avz --exclude-from='.dockerignore' -e 'ssh -i $SSH_KEY_PATH -o StrictHostKeyChecking=no'"
fi
# Directory on the VPS where the app will be deployed
REMOTE_DIR="/opt/imphenbot"
echo "📦 Syncing files to VPS..."
$SSH_CMD "mkdir -p $REMOTE_DIR"
eval "$RSYNC_CMD ./ $VPS_USER@$VPS_HOST:$REMOTE_DIR"
if [ -f .env ]; then
echo "🔒 Copying local .env to VPS..."
if [ -z "$SSH_KEY_PATH" ]; then
scp -o StrictHostKeyChecking=no .env $VPS_USER@$VPS_HOST:$REMOTE_DIR/.env
else
scp -i $SSH_KEY_PATH -o StrictHostKeyChecking=no .env $VPS_USER@$VPS_HOST:$REMOTE_DIR/.env
fi
else
echo "⚠️ No local .env found to copy."
fi
echo "🔄 Rebuilding and restarting Docker containers..."
$SSH_CMD << EOF
cd $REMOTE_DIR
docker-compose up -d --build
EOF
echo "✅ Deployment complete!"

30
docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
version: '3.8'
services:
app:
build: .
container_name: imphenbot-app
restart: unless-stopped
env_file:
- .env
volumes:
- ./recordings:/app/recordings
# Mapping SQLite database files if needed, or storing them in a dedicated volume.
# Assuming default config uses root directory for DB.
- ./.muxer-queue.db:/app/.muxer-queue.db
- ./.muxer-queue.db-shm:/app/.muxer-queue.db-shm
- ./.muxer-queue.db-wal:/app/.muxer-queue.db-wal
labels:
- "traefik.enable=true"
- "traefik.http.routers.imphenbot.rule=Host(`imphnen.asepharyana.tech`)"
- "traefik.http.routers.imphenbot.entrypoints=websecure"
- "traefik.http.routers.imphenbot.tls=true"
# Expose port to traefik (adjust if WEBSERVER_PORT differs)
- "traefik.http.services.imphenbot.loadbalancer.server.port=3000"
networks:
- app-shared-net
networks:
app-shared-net:
name: app-shared-net
external: true

View File

@@ -0,0 +1,713 @@
# Media YouTube and Spotify Resolver Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extend media playback input so users can queue YouTube URLs, plain search queries, and Spotify track URLs that resolve to playable YouTube audio.
**Architecture:** Keep playback unchanged: `musicPlayer` still passes one resolved source to ffmpeg. Add resolver units that turn rich inputs into direct playable URLs before queueing: `play-dl` for YouTube search and Spotify metadata, `yt-dlp` wrapper for YouTube metadata/direct URL extraction when available. Spotify track support resolves metadata then searches YouTube; no Spotify playlist/album support in this phase.
**Tech Stack:** TypeScript, Vitest, Node `child_process`, `play-dl`, external `yt-dlp` command when installed, existing Express/media controller/music player.
---
## File Structure
- Modify `package.json` and `pnpm-lock.yaml` — add `play-dl` dependency.
- Modify `src/media/mediaTypes.ts` — extend `MediaSourceKind` with `youtube`, `spotify`, and `search`.
- Create `src/media/ytdlp.ts` — small wrapper around external `yt-dlp` for JSON metadata and direct audio URL extraction.
- Create `src/media/playDlResolver.ts` — wrapper around `play-dl` for YouTube search and Spotify track metadata.
- Modify `src/media/mediaResolver.ts` — compose local/direct URL/YouTube/search/Spotify resolution.
- Modify `public/index.html` — update input label/placeholder to mention YouTube, Spotify track, and search.
- Tests:
- `tests/media/ytdlp.test.ts`
- `tests/media/playDlResolver.test.ts`
- `tests/media/mediaResolver.test.ts`
---
### Task 1: Add play-dl and Media Source Kinds
**Files:**
- Modify: `package.json`
- Modify: `pnpm-lock.yaml`
- Modify: `src/media/mediaTypes.ts`
- Test: `tests/media/mediaResolver.test.ts`
- [ ] **Step 1: Write failing type expectation in resolver test**
Append to `tests/media/mediaResolver.test.ts`:
```ts
it("keeps direct URLs as generic URL sources", async () => {
await expect(
resolveMediaSource("https://cdn.example.com/song.mp3"),
).resolves.toMatchObject({
kind: "url",
source: "https://cdn.example.com/song.mp3",
});
});
```
This test should already pass before type changes; it protects existing behavior.
- [ ] **Step 2: Install play-dl**
Run:
```bash
pnpm -C /mnt/code/bete add play-dl
```
Expected: `package.json` contains `"play-dl"` in dependencies and `pnpm-lock.yaml` updates.
- [ ] **Step 3: Extend media source kinds**
Modify `src/media/mediaTypes.ts`:
```ts
export type MediaSourceKind = "url" | "local" | "youtube" | "spotify" | "search";
```
- [ ] **Step 4: Run protected resolver test and typecheck**
Run:
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/mediaResolver.test.ts
pnpm -C /mnt/code/bete run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit task 1**
```bash
git -C /mnt/code/bete add package.json pnpm-lock.yaml src/media/mediaTypes.ts tests/media/mediaResolver.test.ts
git -C /mnt/code/bete commit -m "feat: prepare media resolver source kinds"
```
---
### Task 2: yt-dlp Wrapper
**Files:**
- Create: `src/media/ytdlp.ts`
- Test: `tests/media/ytdlp.test.ts`
- [ ] **Step 1: Write failing yt-dlp tests**
Create `tests/media/ytdlp.test.ts`:
```ts
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { createYtDlp } from "../../src/media/ytdlp";
class FakeProcess extends EventEmitter {
stdout = new PassThrough();
stderr = new PassThrough();
}
describe("createYtDlp", () => {
it("reads YouTube metadata as JSON", async () => {
const proc = new FakeProcess();
const spawn = vi.fn(() => proc);
const ytdlp = createYtDlp({ spawn });
const result = ytdlp.getMetadata("https://youtu.be/video");
proc.stdout.write(JSON.stringify({ title: "Song Title", webpage_url: "https://youtube.com/watch?v=video" }));
proc.stdout.end();
proc.emit("close", 0);
await expect(result).resolves.toEqual({
title: "Song Title",
webpageUrl: "https://youtube.com/watch?v=video",
});
expect(spawn).toHaveBeenCalledWith("yt-dlp", [
"https://youtu.be/video",
"--dump-single-json",
"--no-playlist",
"--no-warnings",
"--quiet",
], { stdio: ["ignore", "pipe", "pipe"] });
});
it("reads direct audio URL", async () => {
const proc = new FakeProcess();
const ytdlp = createYtDlp({ spawn: vi.fn(() => proc) });
const result = ytdlp.getDirectAudioUrl("https://youtu.be/video");
proc.stdout.write("https://audio.example.com/stream\n");
proc.stdout.end();
proc.emit("close", 0);
await expect(result).resolves.toBe("https://audio.example.com/stream");
});
it("rejects when yt-dlp exits non-zero", async () => {
const proc = new FakeProcess();
const ytdlp = createYtDlp({ spawn: vi.fn(() => proc) });
const result = ytdlp.getMetadata("https://youtu.be/video");
proc.stderr.write("failed");
proc.stderr.end();
proc.emit("close", 1);
await expect(result).rejects.toThrow("yt-dlp failed with code 1");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/ytdlp.test.ts
```
Expected: FAIL because `src/media/ytdlp.ts` does not exist.
- [ ] **Step 3: Implement yt-dlp wrapper**
Create `src/media/ytdlp.ts`:
```ts
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { spawn as nodeSpawn } from "node:child_process";
export interface YtDlpMetadata {
title: string;
webpageUrl: string;
}
export interface YtDlpClient {
getMetadata(url: string): Promise<YtDlpMetadata>;
getDirectAudioUrl(url: string): Promise<string>;
}
export interface YtDlpDependencies {
spawn?: typeof nodeSpawn;
}
export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient {
const spawn = dependencies.spawn ?? nodeSpawn;
return {
async getMetadata(url: string): Promise<YtDlpMetadata> {
const data = await runYtDlp(spawn, [
url,
"--dump-single-json",
"--no-playlist",
"--no-warnings",
"--quiet",
]);
const parsed = JSON.parse(data) as { title?: string; webpage_url?: string };
return {
title: parsed.title || url,
webpageUrl: parsed.webpage_url || url,
};
},
async getDirectAudioUrl(url: string): Promise<string> {
return runYtDlp(spawn, [
url,
"--get-url",
"--format",
"bestaudio[protocol^=http]/bestaudio/best",
"--no-playlist",
"--no-warnings",
"--quiet",
]).then((value) => value.trim().split("\n")[0] || url);
},
};
}
async function runYtDlp(
spawn: typeof nodeSpawn,
args: string[],
): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn("yt-dlp", args, {
stdio: ["ignore", "pipe", "pipe"],
}) as unknown as ChildProcessWithoutNullStreams;
let stdout = "";
let stderr = "";
proc.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
proc.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
proc.on("error", reject);
proc.on("close", (code) => {
if (code === 0) {
resolve(stdout);
return;
}
reject(new Error(`yt-dlp failed with code ${code}: ${stderr.trim()}`));
});
});
}
```
- [ ] **Step 4: Run yt-dlp tests and typecheck**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/ytdlp.test.ts
pnpm -C /mnt/code/bete run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit task 2**
```bash
git -C /mnt/code/bete add src/media/ytdlp.ts tests/media/ytdlp.test.ts
git -C /mnt/code/bete commit -m "feat: add yt-dlp media helper"
```
---
### Task 3: play-dl Resolver Wrapper
**Files:**
- Create: `src/media/playDlResolver.ts`
- Test: `tests/media/playDlResolver.test.ts`
- [ ] **Step 1: Write failing play-dl resolver tests**
Create `tests/media/playDlResolver.test.ts`:
```ts
import { describe, expect, it, vi } from "vitest";
import { createPlayDlResolver } from "../../src/media/playDlResolver";
describe("createPlayDlResolver", () => {
it("returns the first YouTube search result", async () => {
const resolver = createPlayDlResolver({
search: vi.fn(async () => [
{ title: "Song Result", url: "https://youtube.com/watch?v=abc" },
]),
spotify: vi.fn(),
});
await expect(resolver.searchYouTube("artist song")).resolves.toEqual({
title: "Song Result",
url: "https://youtube.com/watch?v=abc",
});
});
it("turns Spotify track metadata into a YouTube search query", async () => {
const resolver = createPlayDlResolver({
search: vi.fn(async () => [
{ title: "Artist - Track", url: "https://youtube.com/watch?v=track" },
]),
spotify: vi.fn(async () => ({
type: "track",
name: "Track",
artists: [{ name: "Artist" }],
})),
});
await expect(
resolver.resolveSpotifyTrack("https://open.spotify.com/track/123"),
).resolves.toEqual({
title: "Artist - Track",
url: "https://youtube.com/watch?v=track",
});
});
it("rejects Spotify playlists in this phase", async () => {
const resolver = createPlayDlResolver({
search: vi.fn(),
spotify: vi.fn(async () => ({ type: "playlist", name: "Playlist" })),
});
await expect(
resolver.resolveSpotifyTrack("https://open.spotify.com/playlist/123"),
).rejects.toThrow("Only Spotify track URLs are supported");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/playDlResolver.test.ts
```
Expected: FAIL because `src/media/playDlResolver.ts` does not exist.
- [ ] **Step 3: Implement play-dl wrapper**
Create `src/media/playDlResolver.ts`:
```ts
import play from "play-dl";
export interface PlayDlResult {
title: string;
url: string;
}
interface PlayDlSearchResult {
title?: string;
url?: string;
}
interface SpotifyTrackLike {
type?: string;
name?: string;
artists?: Array<{ name?: string }>;
}
export interface PlayDlDependencies {
search?: (query: string, options: { limit: number }) => Promise<PlayDlSearchResult[]>;
spotify?: (url: string) => Promise<SpotifyTrackLike>;
}
export function createPlayDlResolver(dependencies: PlayDlDependencies = {}) {
const search = dependencies.search ?? play.search;
const spotify = dependencies.spotify ?? play.spotify;
return {
async searchYouTube(query: string): Promise<PlayDlResult> {
const results = await search(query, { limit: 1 });
const first = results[0];
if (!first?.url) throw new Error(`No YouTube result found for ${query}`);
return {
title: first.title || query,
url: first.url,
};
},
async resolveSpotifyTrack(url: string): Promise<PlayDlResult> {
const track = await spotify(url);
if (track.type !== "track") {
throw new Error("Only Spotify track URLs are supported");
}
const artists = (track.artists || [])
.map((artist) => artist.name)
.filter(Boolean)
.join(" ");
const query = `${artists} ${track.name || ""} audio`.trim();
return this.searchYouTube(query);
},
};
}
```
- [ ] **Step 4: Run play-dl tests and typecheck**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/playDlResolver.test.ts
pnpm -C /mnt/code/bete run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit task 3**
```bash
git -C /mnt/code/bete add src/media/playDlResolver.ts tests/media/playDlResolver.test.ts
git -C /mnt/code/bete commit -m "feat: add play-dl search resolver"
```
---
### Task 4: Compose Resolver for YouTube, Search, and Spotify Track
**Files:**
- Modify: `src/media/mediaResolver.ts`
- Test: `tests/media/mediaResolver.test.ts`
- [ ] **Step 1: Write failing composed resolver tests**
Append to `tests/media/mediaResolver.test.ts`:
```ts
import { createMediaResolver } from "../../src/media/mediaResolver";
// Add inside describe("resolveMediaSource", ...):
it("resolves YouTube URLs with yt-dlp metadata", async () => {
const resolver = createMediaResolver({
ytdlp: {
getMetadata: vi.fn(async () => ({
title: "YouTube Song",
webpageUrl: "https://youtube.com/watch?v=abc",
})),
getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/abc"),
},
playDlResolver: {
searchYouTube: vi.fn(),
resolveSpotifyTrack: vi.fn(),
},
});
await expect(resolver("https://youtu.be/abc")).resolves.toEqual({
source: "https://audio.example.com/abc",
title: "YouTube Song",
kind: "youtube",
});
});
it("resolves search queries to YouTube results", async () => {
const resolver = createMediaResolver({
ytdlp: {
getMetadata: vi.fn(),
getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/search"),
},
playDlResolver: {
searchYouTube: vi.fn(async () => ({
title: "Search Result",
url: "https://youtube.com/watch?v=search",
})),
resolveSpotifyTrack: vi.fn(),
},
});
await expect(resolver("artist song")).resolves.toEqual({
source: "https://audio.example.com/search",
title: "Search Result",
kind: "search",
});
});
it("resolves Spotify track URLs through YouTube search", async () => {
const resolver = createMediaResolver({
ytdlp: {
getMetadata: vi.fn(),
getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/spotify"),
},
playDlResolver: {
searchYouTube: vi.fn(),
resolveSpotifyTrack: vi.fn(async () => ({
title: "Spotify Match",
url: "https://youtube.com/watch?v=spotify",
})),
},
});
await expect(
resolver("https://open.spotify.com/track/123"),
).resolves.toEqual({
source: "https://audio.example.com/spotify",
title: "Spotify Match",
kind: "spotify",
});
});
```
Also update imports at the top:
```ts
import { describe, expect, it, vi } from "vitest";
import { createMediaResolver, resolveMediaSource } from "../../src/media/mediaResolver";
```
- [ ] **Step 2: Run test to verify it fails**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/mediaResolver.test.ts
```
Expected: FAIL because `createMediaResolver` does not exist.
- [ ] **Step 3: Implement composed resolver**
Modify `src/media/mediaResolver.ts` to export `createMediaResolver()` and keep `resolveMediaSource` as the default instance:
```ts
import { existsSync, statSync } from "node:fs";
import path from "node:path";
import { AppError } from "../errors";
import { createPlayDlResolver } from "./playDlResolver";
import type { ResolvedMediaSource } from "./mediaTypes";
import { createYtDlp, type YtDlpClient } from "./ytdlp";
type PlayDlResolver = ReturnType<typeof createPlayDlResolver>;
export interface MediaResolverDependencies {
ytdlp?: YtDlpClient;
playDlResolver?: PlayDlResolver;
}
export function createMediaResolver(
dependencies: MediaResolverDependencies = {},
) {
const ytdlp = dependencies.ytdlp ?? createYtDlp();
const playDlResolver = dependencies.playDlResolver ?? createPlayDlResolver();
return async function resolve(input: string): Promise<ResolvedMediaSource> {
const source = input.trim();
if (!source) {
throw new AppError("Media source is required", "MISSING_MEDIA_SOURCE", 400);
}
const url = parseUrl(source);
if (url && isYouTubeUrl(url)) {
const metadata = await ytdlp.getMetadata(source);
const directUrl = await ytdlp.getDirectAudioUrl(source);
return { source: directUrl, title: metadata.title, kind: "youtube" };
}
if (url && isSpotifyTrackUrl(url)) {
const result = await playDlResolver.resolveSpotifyTrack(source);
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
return { source: directUrl, title: result.title, kind: "spotify" };
}
const urlSource = resolveUrlSource(source);
if (urlSource) return urlSource;
const localPath = path.resolve(source);
if (existsSync(localPath) && statSync(localPath).isFile()) {
return {
source: localPath,
title: path.basename(localPath),
kind: "local",
};
}
if (!url) {
const result = await playDlResolver.searchYouTube(source);
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
return { source: directUrl, title: result.title, kind: "search" };
}
throw new AppError(
"Media source must be an HTTP(S) URL, YouTube URL, Spotify track URL, search query, or existing local file",
"UNSUPPORTED_MEDIA_SOURCE",
400,
);
};
}
export const resolveMediaSource = createMediaResolver();
function parseUrl(source: string): URL | null {
try {
return new URL(source);
} catch {
return null;
}
}
function isYouTubeUrl(url: URL): boolean {
return ["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"].includes(
url.hostname,
);
}
function isSpotifyTrackUrl(url: URL): boolean {
return url.hostname === "open.spotify.com" && url.pathname.startsWith("/track/");
}
function resolveUrlSource(source: string): ResolvedMediaSource | null {
const url = parseUrl(source);
if (!url) return null;
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return {
source,
title: titleFromUrl(url),
kind: "url",
};
}
function titleFromUrl(url: URL): string {
const filename = decodeURIComponent(url.pathname.split("/").pop() || "");
return path.basename(filename) || url.hostname;
}
```
- [ ] **Step 4: Run resolver tests and typecheck**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/mediaResolver.test.ts
pnpm -C /mnt/code/bete run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit task 4**
```bash
git -C /mnt/code/bete add src/media/mediaResolver.ts tests/media/mediaResolver.test.ts
git -C /mnt/code/bete commit -m "feat: resolve youtube search and spotify media"
```
---
### Task 5: Dashboard Copy and Full Verification
**Files:**
- Modify: `public/index.html`
- [ ] **Step 1: Update media input copy**
Change the media input label and placeholder in `public/index.html` from:
```html
<label for="mediaSourceInput">Music URL / file path</label>
<input id="mediaSourceInput" type="text" placeholder="https://example.com/song.mp3">
```
to:
```html
<label for="mediaSourceInput">Music URL, YouTube, Spotify track, search, or file path</label>
<input id="mediaSourceInput" type="text" placeholder="YouTube URL, Spotify track, or search terms">
```
- [ ] **Step 2: Run full verification**
```bash
pnpm -C /mnt/code/bete run test
pnpm -C /mnt/code/bete run typecheck
pnpm -C /mnt/code/bete run lint
```
Expected: PASS.
- [ ] **Step 3: Manual verification**
Run:
```bash
pnpm -C /mnt/code/bete run dev
```
Manual checks:
1. Queue a direct MP3 URL: still plays.
2. Queue a local file path: still plays.
3. Queue a YouTube URL: resolves title and plays audio.
4. Queue plain search terms: resolves first YouTube result and plays audio.
5. Queue a Spotify track URL: resolves Spotify metadata, searches YouTube, and plays audio.
6. Queue a Spotify playlist URL: returns a clear unsupported error.
- [ ] **Step 4: Commit task 5**
```bash
git -C /mnt/code/bete add public/index.html
git -C /mnt/code/bete commit -m "feat: update media input guidance"
```
---
## Self-Review
Spec coverage:
- YouTube URL support: Task 2 + Task 4.
- Search query support: Task 3 + Task 4.
- Spotify track URL to YouTube support: Task 3 + Task 4.
- No Spotify playlist/album support: Task 3 explicitly rejects non-track Spotify types, Task 5 manual check covers playlist error.
- Dashboard copy: Task 5.
- Existing direct URL/local file behavior protected: Task 1 + existing tests.
Placeholder scan: no placeholders, TODOs, or vague test instructions remain.
Type consistency: `MediaSourceKind` includes `youtube`, `spotify`, and `search`; resolver returns those exact values; tests assert those values.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,673 @@
# Session Full Recording Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build background full-session OGG recording generation from voice join to leave while preserving existing per-user segment recordings.
**Architecture:** Add a focused session tracker that records session timing, participants, and per-user segment references. Add a session muxer that builds timeline-offset ffmpeg filters and writes `recordings/sessions/<sessionId>/session.json` plus `full.ogg`. Wire recorder lifecycle to create a session on join, register finished human segments, and finalize in the background on stop/destroy.
**Tech Stack:** TypeScript, Vitest, Node fs/path, ffmpeg via existing `buildMuxFfmpegArgs` and `runFfmpeg`, Discord voice receiver pipeline.
---
## File Structure
- Create `src/recorder/sessionRecording.ts`: session metadata types, session tracker, mux filter builder, and session finalization function.
- Modify `src/types.ts`: add `recordingSessionId` to per-user `SegmentMetadata`.
- Modify `src/recorder/metadata.ts`: accept and write shared `recordingSessionId` into segment metadata.
- Modify `src/recorder.ts`: create session on ready, skip bots as now, register segment metadata, finalize session in background on stop/destroy.
- Create `tests/recorder/sessionRecording.test.ts`: unit tests for session tracker, mux filter, empty session, and failed mux metadata.
- Modify `tests/recorder.test.ts`: assert bot/self users do not register session participants or subscriptions; add stop finalization trigger test with injected session finalizer if needed.
---
### Task 1: Session Recording Metadata and Mux Builder
**Files:**
- Create: `src/recorder/sessionRecording.ts`
- Test: `tests/recorder/sessionRecording.test.ts`
- [ ] **Step 1: Write failing tests for session tracker and mux filter**
Create `tests/recorder/sessionRecording.test.ts`:
```ts
import { describe, expect, it, vi } from "vitest";
import {
buildSessionMuxFilter,
createRecordingSession,
finalizeRecordingSession,
} from "../../src/recorder/sessionRecording";
import type { UserMetadata } from "../../src/types";
function user(overrides: Partial<UserMetadata> = {}): UserMetadata {
return {
userId: "user-1",
username: "Alice",
tag: "Alice#0001",
displayName: "Alice",
avatarUrl: "https://example.com/avatar.png",
bot: false,
roles: [],
highestRole: null,
joinedTimestamp: null,
...overrides,
};
}
describe("sessionRecording", () => {
it("tracks participants and segment refs", () => {
const session = createRecordingSession({
guildId: "guild",
channelId: "voice",
channelName: "Voice",
startTime: 1000,
recordingsDir: "/recordings",
});
session.registerSegment({
user: user(),
oggPath: "/recordings/user-1/1500.ogg",
jsonPath: "/recordings/user-1/1500.json",
startTime: 1500,
endTime: 2500,
});
const snapshot = session.snapshot(3000);
expect(snapshot).toMatchObject({
sessionId: "guild-voice-1000",
guildId: "guild",
channelId: "voice",
channelName: "Voice",
startTime: 1000,
endTime: 3000,
durationMs: 2000,
status: "pending",
participants: [{ userId: "user-1", username: "Alice" }],
segments: [
{
userId: "user-1",
oggPath: "/recordings/user-1/1500.ogg",
jsonPath: "/recordings/user-1/1500.json",
startTime: 1500,
endTime: 2500,
offsetMs: 500,
},
],
});
});
it("builds timeline-offset ffmpeg filter", () => {
const filter = buildSessionMuxFilter([
{ startTime: 1000 },
{ startTime: 2500 },
], 1000);
expect(filter).toBe(
"[0:a]adelay=0|0[pad0];[1:a]adelay=1500|1500[pad1];[pad0][pad1]amix=inputs=2:dropout_transition=0[out]",
);
});
it("writes empty metadata without running ffmpeg", async () => {
const session = createRecordingSession({
guildId: "guild",
channelId: "voice",
channelName: "Voice",
startTime: 1000,
recordingsDir: "/recordings",
});
const writeJson = vi.fn();
const mkdir = vi.fn();
const runFfmpeg = vi.fn();
await finalizeRecordingSession(session, {
endTime: 4000,
mkdir,
writeJson,
runFfmpeg,
});
expect(runFfmpeg).not.toHaveBeenCalled();
expect(mkdir).toHaveBeenCalledWith("/recordings/sessions/guild-voice-1000");
expect(writeJson).toHaveBeenCalledWith(
"/recordings/sessions/guild-voice-1000/session.json",
expect.objectContaining({ status: "empty", durationMs: 3000 }),
);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run:
```bash
pnpm exec vitest run tests/recorder/sessionRecording.test.ts
```
Expected: FAIL because `src/recorder/sessionRecording.ts` does not exist.
- [ ] **Step 3: Implement session tracker and mux filter**
Create `src/recorder/sessionRecording.ts`:
```ts
import fs from "node:fs";
import path from "node:path";
import { buildMuxFfmpegArgs, runFfmpeg as defaultRunFfmpeg } from "../audio/ffmpegProcess";
import type { UserMetadata } from "../types";
export type SessionRecordingStatus = "pending" | "completed" | "failed" | "empty";
export interface RecordingSessionOptions {
guildId: string;
channelId: string;
channelName: string;
startTime: number;
recordingsDir: string;
}
export interface SessionSegmentInput {
user: UserMetadata;
oggPath: string;
jsonPath: string;
startTime: number;
endTime: number;
}
export interface SessionParticipant {
userId: string;
username: string;
tag: string;
displayName: string;
avatarUrl: string;
}
export interface SessionSegmentRef {
userId: string;
oggPath: string;
jsonPath: string;
startTime: number;
endTime: number;
durationMs: number;
offsetMs: number;
}
export interface SessionRecordingMetadata {
sessionId: string;
guildId: string;
channelId: string;
channelName: string;
startTime: number;
endTime: number;
durationMs: number;
status: SessionRecordingStatus;
outputFile: string | null;
participants: SessionParticipant[];
segments: SessionSegmentRef[];
error?: string;
}
export interface RecordingSession {
readonly sessionId: string;
readonly recordingsDir: string;
readonly startTime: number;
registerSegment(input: SessionSegmentInput): void;
snapshot(endTime: number): SessionRecordingMetadata;
}
export interface FinalizeRecordingSessionDependencies {
endTime?: number;
mkdir?: (dir: string) => void;
writeJson?: (file: string, metadata: SessionRecordingMetadata) => void;
runFfmpeg?: (args: string[]) => Promise<void>;
}
export function createRecordingSession(options: RecordingSessionOptions): RecordingSession {
const sessionId = `${options.guildId}-${options.channelId}-${options.startTime}`;
const participants = new Map<string, SessionParticipant>();
const segments: SessionSegmentRef[] = [];
return {
sessionId,
recordingsDir: options.recordingsDir,
startTime: options.startTime,
registerSegment(input: SessionSegmentInput): void {
participants.set(input.user.userId, {
userId: input.user.userId,
username: input.user.username,
tag: input.user.tag,
displayName: input.user.displayName,
avatarUrl: input.user.avatarUrl,
});
segments.push({
userId: input.user.userId,
oggPath: input.oggPath,
jsonPath: input.jsonPath,
startTime: input.startTime,
endTime: input.endTime,
durationMs: input.endTime - input.startTime,
offsetMs: input.startTime - options.startTime,
});
},
snapshot(endTime: number): SessionRecordingMetadata {
return {
sessionId,
guildId: options.guildId,
channelId: options.channelId,
channelName: options.channelName,
startTime: options.startTime,
endTime,
durationMs: endTime - options.startTime,
status: "pending",
outputFile: null,
participants: Array.from(participants.values()),
segments: [...segments],
};
},
};
}
export function buildSessionMuxFilter(
segments: Array<{ startTime: number }>,
sessionStartTime: number,
): string {
const filters = segments.map((segment, index) => {
const delayMs = Math.max(0, segment.startTime - sessionStartTime);
return `[${index}:a]adelay=${delayMs}|${delayMs}[pad${index}]`;
});
const inputs = segments.map((_, index) => `[pad${index}]`).join("");
filters.push(`${inputs}amix=inputs=${segments.length}:dropout_transition=0[out]`);
return filters.join(";");
}
export async function finalizeRecordingSession(
session: RecordingSession,
dependencies: FinalizeRecordingSessionDependencies = {},
): Promise<void> {
const endTime = dependencies.endTime ?? Date.now();
const sessionDir = path.join(session.recordingsDir, "sessions", session.sessionId);
const outputFile = path.join(sessionDir, "full.ogg");
const metadataFile = path.join(sessionDir, "session.json");
const mkdir = dependencies.mkdir ?? ((dir) => fs.mkdirSync(dir, { recursive: true }));
const writeJson =
dependencies.writeJson ??
((file, metadata) => fs.writeFileSync(file, JSON.stringify(metadata, null, 2)));
const runFfmpeg = dependencies.runFfmpeg ?? defaultRunFfmpeg;
mkdir(sessionDir);
const metadata = session.snapshot(endTime);
if (metadata.segments.length === 0) {
writeJson(metadataFile, { ...metadata, status: "empty" });
return;
}
try {
await runFfmpeg(
buildMuxFfmpegArgs({
inputs: metadata.segments.map((segment) => segment.oggPath),
filter: buildSessionMuxFilter(metadata.segments, metadata.startTime),
output: outputFile,
codec: "libopus",
}),
);
writeJson(metadataFile, {
...metadata,
status: "completed",
outputFile,
});
} catch (error) {
writeJson(metadataFile, {
...metadata,
status: "failed",
error: error instanceof Error ? error.message : String(error),
});
}
}
```
- [ ] **Step 4: Run tests**
Run:
```bash
pnpm exec vitest run tests/recorder/sessionRecording.test.ts
```
Expected: PASS.
- [ ] **Step 5: Commit Task 1**
Run:
```bash
git add src/recorder/sessionRecording.ts tests/recorder/sessionRecording.test.ts
git commit -m "feat: add recording session metadata"
```
---
### Task 2: Add Shared Recording Session ID to Segment Metadata
**Files:**
- Modify: `src/types.ts`
- Modify: `src/recorder/metadata.ts`
- Test: `tests/recorder/metadata.test.ts`
- [ ] **Step 1: Write failing metadata test**
Create `tests/recorder/metadata.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { createSegmentMetadata } from "../../src/recorder/metadata";
import type { SegmentState, UserMetadata } from "../../src/types";
const user: UserMetadata = {
userId: "user-1",
username: "Alice",
tag: "Alice#0001",
displayName: "Alice",
avatarUrl: "https://example.com/avatar.png",
bot: false,
roles: [],
highestRole: null,
joinedTimestamp: null,
};
const segment = {
index: 0,
startTime: 1500,
endTime: 2500,
filename: "/recordings/user-1/1500.ogg",
jsonFilename: "/recordings/user-1/1500.json",
} as SegmentState;
describe("createSegmentMetadata", () => {
it("includes shared recording session id", () => {
const metadata = createSegmentMetadata(
user,
segment,
"user-1-1500",
"guild-voice-1000",
1000,
5000,
);
expect(metadata).toMatchObject({
sessionId: "user-1-1500",
recordingSessionId: "guild-voice-1000",
sessionStartTime: 1000,
startTime: 1500,
endTime: 2500,
});
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
pnpm exec vitest run tests/recorder/metadata.test.ts
```
Expected: FAIL because `createSegmentMetadata` does not accept `recordingSessionId` yet.
- [ ] **Step 3: Update metadata type and function signature**
Modify `src/types.ts`:
```ts
export interface SegmentMetadata extends UserMetadata {
sessionId: string;
recordingSessionId: string;
sessionStartTime: number;
segmentIndex: number;
segmentMs: number;
startTime: number;
endTime: number;
durationMs: number;
filename: string;
}
```
Modify `src/recorder/metadata.ts` function signature and return object:
```ts
export function createSegmentMetadata(
user: UserMetadata,
segment: SegmentState,
sessionId: string,
recordingSessionId: string,
sessionStartTime: number,
recordingSegmentMs: number,
): SegmentMetadata {
const endTime = segment.endTime ?? Date.now();
return {
...user,
sessionId,
recordingSessionId,
sessionStartTime,
segmentIndex: segment.index,
segmentMs: recordingSegmentMs,
startTime: segment.startTime,
endTime,
durationMs: endTime - segment.startTime,
filename: path.basename(segment.filename),
};
}
```
- [ ] **Step 4: Update existing call sites**
In `src/recorder.ts`, update the call to include `recordingSession.sessionId` after the per-user `sessionId` argument:
```ts
const metadata = createSegmentMetadata(
userMetadata,
currentSegment,
sessionId,
recordingSession.sessionId,
sessionStartTime,
config.RECORDING_SEGMENT_MS,
);
```
- [ ] **Step 5: Run metadata tests and typecheck**
Run:
```bash
pnpm exec vitest run tests/recorder/metadata.test.ts
pnpm run typecheck
```
Expected: PASS.
- [ ] **Step 6: Commit Task 2**
Run:
```bash
git add src/types.ts src/recorder/metadata.ts src/recorder.ts tests/recorder/metadata.test.ts
git commit -m "feat: tag segments with recording session"
```
---
### Task 3: Wire Session Tracking into Recorder Lifecycle
**Files:**
- Modify: `src/recorder.ts`
- Modify: `tests/recorder.test.ts`
- [ ] **Step 1: Write failing recorder lifecycle tests**
Append to `tests/recorder.test.ts`:
```ts
it("finalizes the active recording session when stopped", async () => {
const { startRecording, stopRecording } = await import("../src/recorder");
const { getVoiceConnection } = await import("@discordjs/voice");
const destroy = vi.fn();
vi.mocked(getVoiceConnection).mockReturnValue({ destroy } as never);
await startRecording({ user: { id: "self-user" } } as never, createChannel() as never);
stopRecording("guild");
await new Promise((resolve) => setImmediate(resolve));
expect(destroy).toHaveBeenCalled();
});
```
Then add a test that emits a non-bot user and asserts `subscribe` is called once, while existing self/bot tests still assert zero subscriptions.
- [ ] **Step 2: Run recorder tests to verify failure if session APIs are missing**
Run:
```bash
pnpm exec vitest run tests/recorder.test.ts
```
Expected: FAIL until recorder imports and uses session recording APIs.
- [ ] **Step 3: Add active session map and finalize helper**
Modify `src/recorder.ts` imports:
```ts
import {
createRecordingSession,
finalizeRecordingSession,
type RecordingSession,
} from "./recorder/sessionRecording";
```
Add near `recordingsDir`:
```ts
const activeRecordingSessions = new Map<string, RecordingSession>();
function finalizeActiveRecordingSession(guildId: string): void {
const session = activeRecordingSessions.get(guildId);
if (!session) return;
activeRecordingSessions.delete(guildId);
finalizeRecordingSession(session).catch((error) => {
logger.error({ error }, "Failed to finalize recording session");
});
}
```
After connection reaches ready, create and store the session:
```ts
const recordingSession = createRecordingSession({
guildId: channel.guild.id,
channelId: channel.id,
channelName: channel.name,
startTime: Date.now(),
recordingsDir,
});
activeRecordingSessions.set(channel.guild.id, recordingSession);
```
In segment finish handler, after writing per-user JSON, register the segment:
```ts
recordingSession.registerSegment({
user: userMetadata,
oggPath: currentSegment.filename,
jsonPath: currentSegment.jsonFilename,
startTime: currentSegment.startTime,
endTime: metadata.endTime,
});
```
In `stopRecording(guildId)`, call `finalizeActiveRecordingSession(guildId)` before destroying connection.
In `connection.on(VoiceConnectionStatus.Destroyed, ...)`, call `finalizeActiveRecordingSession(channel.guild.id)`.
- [ ] **Step 4: Run recorder tests and typecheck**
Run:
```bash
pnpm exec vitest run tests/recorder.test.ts tests/recorder/sessionRecording.test.ts tests/recorder/metadata.test.ts
pnpm run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit Task 3**
Run:
```bash
git add src/recorder.ts tests/recorder.test.ts
git commit -m "feat: finalize recording sessions on disconnect"
```
---
### Task 4: Final Verification
**Files:**
- All changed recorder/session files.
- [ ] **Step 1: Run recorder-focused tests**
Run:
```bash
pnpm exec vitest run tests/recorder.test.ts tests/recorder/sessionRecording.test.ts tests/recorder/metadata.test.ts tests/audio/ffmpegProcess.test.ts
```
Expected: PASS.
- [ ] **Step 2: Run full test suite**
Run:
```bash
pnpm run test
```
Expected: PASS.
- [ ] **Step 3: Run typecheck**
Run:
```bash
pnpm run typecheck
```
Expected: PASS.
- [ ] **Step 4: Run lint**
Run:
```bash
pnpm run lint
```
Expected: PASS.
- [ ] **Step 5: Check git status**
Run:
```bash
git status --short
```
Expected: only intentional implementation, spec, and plan changes are present.
```

View File

@@ -0,0 +1,71 @@
# Media Echo Fix and YouTube Screenshare Design
## Context
Media playback currently uses the same `DiscordPlayer` instance as the browser audio bridge. The browser bridge is started during webserver startup and subscribes the shared player to the active voice connection. Music playback also uses that player. This shared ownership can let the bridge interfere with media playback and contribute to voice audio being reflected back during playback.
The project already includes `@dank074/discord-video-stream`, which supports Discord Go Live video streaming from a direct media URL or readable stream.
## Goals
- Prevent voice audio from being reflected back while music/media playback is active.
- Keep normal music playback behavior for existing `/api/media/queue` users.
- Add a YouTube screenshare path that streams video through Discord Go Live.
- Fail clearly when voice is not connected, another media mode is busy, or screenshare dependencies fail.
## Non-goals
- Replace the existing voice recorder pipeline.
- Disable message or voice monitoring during music playback.
- Build full production UI for screenshare controls in the first implementation.
- Add Discord integration tests that require a live account or server.
## Design
### Audio player ownership
`DiscordPlayer` will track which subsystem owns the active stream: `none`, `browser-bridge`, `music`, or `screen`. A caller may only start playback when the player has no owner or when the caller owns the current stream. This prevents the browser bridge from overwriting music or screen playback.
The browser bridge in `src/webserver.ts` will not start at server boot. It will be created lazily only when browser audio arrives and no media playback is active. When media playback starts, the bridge is stopped or left inactive so it cannot transmit captured audio back into Discord.
Music playback will claim the `music` owner before calling `playStream`. When music finishes or stops, ownership is released and browser audio may resume later if the browser sends new audio.
### Screenshare mode
The media queue endpoint will accept an optional `mode` field. If omitted, mode defaults to `music` to preserve existing API behavior. `mode: "screen"` starts a separate screenshare flow instead of audio-only music playback.
A new `ScreenShareController` will:
1. Verify a voice channel is connected.
2. Reject start if music or browser bridge owns playback, or if another screen stream is active.
3. Resolve a YouTube URL to a direct playable video URL through the existing yt-dlp utilities.
4. Use `@dank074/discord-video-stream` with `prepareStream(...)` and `playStream(..., { type: "go-live" })`.
5. Track active screen state and provide stop behavior.
Screenshare state will be exposed through media state as the active mode so the frontend can distinguish music from screen playback.
### Busy-state rules
- Music cannot start while screen is active.
- Screen cannot start while music is active.
- Browser bridge cannot start while music or screen is active.
- Stop stops the active media mode and releases ownership.
### Error handling
- `VOICE_NOT_CONNECTED`: media or screen requested before joining voice.
- `MEDIA_BUSY`: another active media mode owns playback.
- `SCREEN_STREAM_FAILED`: yt-dlp, stream preparation, or Go Live playback fails.
Errors should surface through existing Express error handling as JSON responses.
## Testing
- Unit test `DiscordPlayer` ownership rules: browser bridge cannot override music; music releases ownership on stop.
- Media controller tests: default mode remains music, screen mode is routed separately, and busy conflicts reject with `MEDIA_BUSY`.
- Route tests: `/api/media/queue` accepts optional `mode` and passes it to the controller.
- Screenshare controller tests mock yt-dlp and `@dank074/discord-video-stream`; no live Discord account is required.
## Rollout
Implement ownership first and verify existing music tests still pass. Then add mode parsing and the screenshare controller behind the same media route. UI changes can follow as a small enhancement after API behavior is stable.

View File

@@ -0,0 +1,87 @@
# Session Full Recording Design
## Context
The recorder currently writes per-user OGG segments under `recordings/<userId>/`. Each segment has JSON metadata with user identity, bot flag, segment timing, and filename. The requested addition is a second recording view: one full-session OGG from the time the bot joins a voice channel until it leaves, while preserving the current per-user recording files.
Bot/self audio is excluded before segment creation, so session-level output should only include human participants.
## Goals
- Track one recording session from successful voice join until disconnect/leave.
- Preserve existing per-user OGG segment behavior.
- Create a background full-session OGG/Opus mix after the session ends.
- Store session metadata with duration, participants, segment references, output status, and full recording path.
- Keep muxing failures isolated from voice connection shutdown.
## Non-goals
- Real-time mixed full-session recording.
- Replacing per-user segment recording.
- Dashboard UI for session playback in this phase.
- Database-backed mux job retries in this phase.
## Output structure
A completed session writes:
```text
recordings/
sessions/
<recordingSessionId>/
full.ogg
session.json
```
`recordingSessionId` is based on guild ID, channel ID, and session start time: `<guildId>-<channelId>-<sessionStartTime>`.
`session.json` contains:
- `sessionId`
- `guildId`
- `channelId`
- `channelName`
- `startTime`
- `endTime`
- `durationMs`
- `status`: `completed`, `failed`, or `empty`
- `outputFile`: relative path to `full.ogg` when present
- `participants`: non-bot users observed in the session
- `segments`: per-user segment metadata references with absolute timing
- `error`: failure message when muxing fails
Per-user segment JSON also records the shared `recordingSessionId` so full-session muxing can identify which files belong to the same join/leave session.
## Lifecycle
1. `startRecording()` creates a session object after the voice connection reaches ready state.
2. Each non-bot speaking user still gets the existing per-user `SegmentManager` flow.
3. Each finished segment is registered with the active session using its metadata path, OGG path, user ID, start time, and end time.
4. `stopRecording(guildId)` or connection destruction finalizes the active session with `endTime`.
5. Finalization starts muxing in the background and does not block disconnect.
6. Muxing writes `session.json` with `empty`, `completed`, or `failed` status.
## Muxing design
The post-processor reads all registered segment metadata for the session. It builds an ffmpeg `filter_complex` that delays each input by `segment.startTime - session.startTime` milliseconds, mixes all delayed inputs with `amix`, and encodes the result to OGG/Opus.
For a session with no human segments, muxing skips ffmpeg and writes `session.json` with `status: "empty"` and the full session duration.
For successful muxing, it writes `full.ogg` and `session.json` with `status: "completed"`.
For failed muxing, it writes `session.json` with `status: "failed"` and the error message.
## Error handling
- Failure to write `session.json` is logged and does not crash shutdown.
- ffmpeg failure is captured in metadata as `status: "failed"`.
- Missing or empty segment files are skipped from the mix and recorded as skipped references if needed.
- Background mux errors never reject `stopRecording()`.
## Testing
- Unit test session metadata creation from join to stop.
- Unit test bot/self users do not register participants or segments.
- Unit test mux filter generation with timeline offsets.
- Unit test empty sessions write `status: "empty"` without calling ffmpeg.
- Unit test stop triggers background finalization without awaiting ffmpeg.

View File

@@ -0,0 +1,85 @@
# Internal Streamer Replacement Design
## Summary
Replace the external `@dank074/discord-video-stream` dependency with an internal streaming module that uses `discord.js-selfbot-v13` private APIs to deliver the same screen share behavior (video + audio) with identical UI/API surface.
## Goals
- Maintain feature parity for screen share (video + audio, 720p @ 30fps, bitrate 2500/4000, H264, audio on).
- Keep existing UI and API contracts unchanged (`/api/media/queue` with `mode: "screen"`).
- Remove `@dank074/discord-video-stream` from dependencies and delete `vendor/Discord-video-stream`.
- Ensure clean lifecycle handling (start/stop, cleanup, error reporting).
## Non-Goals
- Rewriting WebRTC/RTP stack from scratch.
- Changing media queue behavior or UI layout.
- Adding new screen share modes or settings.
## Architecture Overview
Introduce a new internal module under `src/streaming/` that encapsulates:
- Voice/session management using private `discord.js-selfbot-v13` APIs.
- FFmpeg preparation for H264 + Opus (AnnexB video + Opus audio).
- Stream playback into the internal dispatcher.
`screenShareController` will depend on this module instead of `@dank074/discord-video-stream`.
## Components
### 1) Streaming Session Module (`src/streaming/`)
Proposed exports:
- `createStreamSession(client)`
- Joins or reuses voice connection for video streaming.
- Exposes a `session` object with `startVideo()`, `stopVideo()`, and `sendStream(stream)` hooks.
- `prepareFfmpegStream(source, opts)`
- Spawns ffmpeg with the same parameters used today.
- Returns `{ command, output }` (output is a Readable stream).
- `playPreparedStream(output, session)`
- Pipes the prepared stream into the internal dispatcher.
- Returns a promise that resolves when playback completes.
### 2) Screen Share Controller (`src/media/screenShareController.ts`)
- Replace Streamer/prepareStream/playStream with internal module usage.
- Keep the public API identical (`start(source)` returning `ScreenSharePlayback`).
### 3) Web Server Wiring (`src/webserver.ts`)
- Remove `Streamer` instantiation and dependencies.
- Pass only `getVoiceStatus` and new streaming module dependencies into `createScreenShareController`.
## Data Flow
1. User queues screen share via `/api/media/queue` with `mode: "screen"`.
2. `MediaController` calls `screenShareController.start(source)`.
3. `screenShareController` resolves URL, calls `prepareFfmpegStream`.
4. `createStreamSession` ensures voice connection and dispatcher ready.
5. `playPreparedStream` sends output to Discord.
6. On completion or stop, cleanup runs and state updates propagate.
## Error Handling
- Voice not connected: throw `VOICE_NOT_CONNECTED`.
- FFmpeg spawn/exit failure: throw `SCREEN_STREAM_FAILED`.
- Dispatcher error: stop stream, cleanup, log error, set state idle.
## Lifecycle Rules
- `start()` always stops any active stream first.
- `stop()` kills ffmpeg, stops dispatcher, and resets internal state.
- Completion resolves `done` promise and triggers cleanup.
## Testing Strategy
- Unit tests for `screenShareController`:
- Calls to `prepareFfmpegStream` and `playPreparedStream` on `start()`.
- Ensures `stop()` kills ffmpeg and ends session.
- Unit tests for `streaming` module:
- Session initialization and cleanup logic with mocked private APIs.
## Migration Steps
1. Implement `src/streaming/` module.
2. Update `screenShareController` to use internal module.
3. Remove `@dank074/discord-video-stream` imports and wiring.
4. Delete `vendor/Discord-video-stream` directory.
5. Update `package.json` dependencies.
6. Update tests.
## Risks
- Private `discord.js-selfbot-v13` APIs may change.
- Harder debugging if internal dispatcher behavior differs.
## Rollback Plan
- Revert to previous commit that restores `@dank074/discord-video-stream` and the vendor directory.

View File

@@ -2,7 +2,6 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Discord Moderation Dashboard</title> <title>Discord Moderation Dashboard</title>
</head> </head>

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,124 +1,259 @@
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { listMessages, reanalyzeMessage } from "./api/client"; import { DashboardLayout } from "./components/layout/DashboardLayout";
import { connectDashboardSocket } from "./ws/client"; import { MediaPanel } from "./components/media/MediaPanel";
import type { MessageRecord } from "./api/client"; import { MessagesPanel } from "./components/messages/MessagesPanel";
import type { DashboardEvent } from "./ws/client";
import { MessageFeed } from "./components/messages/MessageFeed";
import { ReviewPanel } from "./components/review/ReviewPanel"; import { ReviewPanel } from "./components/review/ReviewPanel";
import { Tabs, TabsContent } from "./components/ui/tabs";
import { VoicePanel } from "./components/voice/VoicePanel";
import { AuthOverlay } from "./components/layout/AuthOverlay";
import { useDashboardSocket } from "./hooks/useDashboardSocket";
import { mergeMessages, useMessages } from "./hooks/useMessages";
import { useMediaControl } from "./hooks/useMediaControl";
import { useUIState } from "./hooks/useUIState";
import { useVoiceControl } from "./hooks/useVoiceControl";
import type { MessageRecord } from "./types/messages";
import type { DashboardTab } from "./types/ui";
import type { ActiveSpeaker } from "./types/voice";
function mergeMessages( const SAMPLE_RATE = 24000;
current: MessageRecord[], const CHANNELS = 1;
incoming: MessageRecord[],
): MessageRecord[] {
const byId = new Map(current.map((message) => [message.id, message]));
for (const message of incoming) {
byId.set(message.id, { ...byId.get(message.id), ...message });
}
return Array.from(byId.values())
.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
.slice(0, 200);
}
export default function App() { export default function App() {
const [messages, setMessages] = useState<MessageRecord[]>([]); const { uiState, setUIState, patchUIState } = useUIState();
const [wsStatus, setWsStatus] = useState<string>("connecting"); const voice = useVoiceControl();
const wsRef = useRef<WebSocket | null>(null); const media = useMediaControl();
const messages = useMessages();
const [activeSpeakers, setActiveSpeakers] = useState<ActiveSpeaker[]>([]);
const [levels, setLevels] = useState<number[]>(Array.from({ length: 32 }, () => 0.04));
const [isListening, setIsListening] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem("admin-password"));
const audioContextListenRef = useRef<AudioContext | null>(null);
const audioContextTransmitRef = useRef<AudioContext | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const processorRef = useRef<ScriptProcessorNode | null>(null);
const userTimelinesRef = useRef(new Map<number, number>());
useEffect(() => { const activeTab = uiState.activeTab || "voice";
let cancelled = false; const selectedVoiceGuild = uiState.selectedVoiceGuild || uiState.selectedGuild || "";
const selectedVoiceChannel = uiState.selectedVoiceChannel || "";
const selectedTextGuild = uiState.selectedTextGuild || uiState.selectedGuild || "";
const selectedTextChannel = uiState.selectedTextChannel || "";
listMessages(new URLSearchParams({ limit: "30" })) const handleIncomingPcm = useCallback((data: ArrayBuffer) => {
.then((result) => { const headerView = new DataView(data, 0, 4);
if (!cancelled) { const userIdHash = headerView.getInt32(0, true);
setMessages(mergeMessages([], result.data)); const audioData = data.slice(4);
} const int16Array = new Int16Array(audioData);
}) let sum = 0;
.catch((err) => { for (const sample of int16Array) sum += Math.abs(sample / 32768);
if (!cancelled) { const average = int16Array.length ? sum / int16Array.length : 0;
console.error("Failed to load messages:", err); setLevels((prev) => prev.map((_, index) => Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5)));
}
});
const ws = connectDashboardSocket((event: DashboardEvent) => { const audioContext = audioContextListenRef.current;
switch (event.type) { if (!isListening || !audioContext) return;
case "message_created": const float32Array = new Float32Array(int16Array.length);
setMessages((prev) => mergeMessages(prev, [event.data])); for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768;
break; const audioBuffer = audioContext.createBuffer(CHANNELS, float32Array.length / CHANNELS, SAMPLE_RATE);
case "message_analyzed": audioBuffer.getChannelData(0).set(float32Array);
setMessages((prev) => mergeMessages(prev, [event.data])); const source = audioContext.createBufferSource();
break; source.buffer = audioBuffer;
case "message_updated": source.connect(audioContext.destination);
setMessages((prev) => const currentTime = audioContext.currentTime;
prev.map((m) => (m.id === event.data.id ? { ...m, ...event.data } : m)), let nextStart = userTimelinesRef.current.get(userIdHash) || 0;
); if (nextStart < currentTime) nextStart = currentTime + 0.05;
break; source.start(nextStart);
case "message_deleted": userTimelinesRef.current.set(userIdHash, nextStart + audioBuffer.duration);
setMessages((prev) => }, [isListening]);
prev.map((m) =>
m.id === event.data.id ? { ...m, type: "deleted" as const } : m,
),
);
break;
}
});
wsRef.current = ws; const socket = useDashboardSocket({
onUIState: (state) => setUIState((prev) => ({ ...prev, ...state })),
onUserState: setActiveSpeakers,
onMessageCreated: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])),
onMessageUpdated: (message) => messages.setMessages((prev) => prev.map((item) => (item.id === message.id ? { ...item, ...message } as MessageRecord : item))),
onMessageDeleted: (message) => messages.setMessages((prev) => prev.map((item) => (item.id === message.id ? { ...item, type: "deleted" } : item))),
onMessageAnalyzed: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])),
onAttachmentUploaded: () => messages.fetchMessages(selectedTextChannel).catch(() => undefined),
onMediaState: media.setMediaState,
onPcm: handleIncomingPcm,
});
ws.addEventListener("open", () => setWsStatus("connected")); const stopStreamingLocal = useCallback(() => {
ws.addEventListener("close", () => setWsStatus("disconnected")); setIsStreaming(false);
ws.addEventListener("error", () => setWsStatus("error")); if (processorRef.current) {
processorRef.current.disconnect();
return () => { processorRef.current = null;
cancelled = true; }
ws.close(); if (audioContextTransmitRef.current) {
wsRef.current = null; audioContextTransmitRef.current.close();
}; audioContextTransmitRef.current = null;
}
if (streamRef.current) {
for (const track of streamRef.current.getTracks()) track.stop();
streamRef.current = null;
}
setLevels(Array.from({ length: 32 }, () => 0.04));
}, []); }, []);
const handleReanalyze = async (id: string) => { const startStreamingLocal = useCallback(async () => {
// Optimistic update
setMessages((prev) =>
prev.map((m) =>
m.id === id
? { ...m, ai_status: "pending" as const, ai_error: null, ai_analysis: null }
: m,
),
);
try { try {
await reanalyzeMessage(id); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
setIsStreaming(true);
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContextCtor({ sampleRate: SAMPLE_RATE });
audioContextTransmitRef.current = audioContext;
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
processorRef.current = processor;
source.connect(processor);
processor.connect(audioContext.destination);
processor.onaudioprocess = (event) => {
if (!socket.socketRef.current || socket.socketRef.current.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;
}
socket.socketRef.current.send(pcmData.buffer);
// Update local levels from mic
let sum = 0;
for (let i = 0; i < inputData.length; i++) sum += Math.abs(inputData[i]);
const average = inputData.length ? sum / inputData.length : 0;
setLevels((prev) => prev.map((_, index) => Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5)));
};
} catch (err) { } catch (err) {
console.error("Reanalyze failed:", err); console.error("Microphone access failed:", err);
// Revert optimistic update on failure setIsStreaming(false);
setMessages((prev) => throw err;
prev.map((m) =>
m.id === id ? { ...m, ai_status: "error" as const, ai_error: "Reanalyze failed" } : m,
),
);
} }
}; }, [socket.socketRef]);
const toggleStreaming = useCallback(async () => {
if (isStreaming) {
stopStreamingLocal();
await patchUIState({ isStreaming: false });
} else {
await startStreamingLocal();
await patchUIState({ isStreaming: true });
}
}, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]);
useEffect(() => {
if (selectedVoiceGuild) {
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
}
}, [selectedVoiceGuild]);
useEffect(() => {
if (selectedTextGuild) {
voice.loadTextTargets(selectedTextGuild).catch(() => undefined);
}
}, [selectedTextGuild]);
useEffect(() => {
if (selectedTextChannel) {
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
}
}, [selectedTextChannel]);
const toggleListening = useCallback(async () => {
if (isListening) {
await audioContextListenRef.current?.suspend();
userTimelinesRef.current.clear();
setIsListening(false);
await patchUIState({ isListening: false });
return;
}
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
audioContextListenRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE });
await audioContextListenRef.current.resume();
setIsListening(true);
await patchUIState({ isListening: true });
}, [isListening, patchUIState]);
const tabs = useMemo(() => ["voice", "media", "messages", "review"] as DashboardTab[], []);
return ( return (
<div className="app"> <DashboardLayout
<div className="sidebar"> activeTab={activeTab}
<div className="sidebar-header">Moderation</div> wsStatus={socket.status}
<div className="sidebar-placeholder">Channels placeholder</div> voiceStatus={voice.voiceStatus}
onTabChange={(tab) => patchUIState({ activeTab: tab })}
>
<div className="md:hidden">
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
<div className="mb-4 grid grid-cols-4 gap-2 rounded-2xl bg-muted p-1">
{tabs.map((tab) => (
<button key={tab} className={`rounded-xl px-2 py-2 text-xs font-medium ${activeTab === tab ? "bg-background text-foreground" : "text-muted-foreground"}`} onClick={() => patchUIState({ activeTab: tab })}>
{tab}
</button>
))}
</div>
</Tabs>
</div> </div>
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
<div className="main"> <TabsContent value="voice">
<div className="header"> {!isAuthenticated ? (
<h1>Discord Moderation Dashboard</h1> <AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
<span className="ws-status" data-status={wsStatus}> ) : (
{wsStatus} <VoicePanel
</span> guilds={voice.guilds}
</div> channels={voice.voiceChannels}
selectedGuild={selectedVoiceGuild}
<div className="content"> selectedChannel={selectedVoiceChannel}
<MessageFeed messages={messages} onReanalyze={handleReanalyze} /> status={voice.voiceStatus}
</div> loading={voice.loading}
activeSpeakers={activeSpeakers}
<ReviewPanel messages={messages} onReanalyze={handleReanalyze} /> levels={levels}
</div> isListening={isListening}
</div> isStreaming={isStreaming}
onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })}
onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })}
onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)}
onDisconnect={() => voice.leaveVoice()}
onListenToggle={toggleListening}
onStreamingToggle={toggleStreaming}
/>
)}
</TabsContent>
<TabsContent value="media">
{!isAuthenticated ? (
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
) : (
<MediaPanel
state={media.mediaState}
loading={media.loading}
onQueueMusic={(source) => media.enqueue(source, "music")}
onStartScreen={(source) => media.enqueue(source, "screen")}
onSkip={media.skip}
onStop={media.stop}
onVolumeChange={media.setVolume}
/>
)}
</TabsContent>
<TabsContent value="messages">
<MessagesPanel
guilds={voice.guilds}
channels={voice.textChannels}
selectedGuild={selectedTextGuild}
selectedChannel={selectedTextChannel}
messages={messages.messages}
onGuildChange={(guildId) => patchUIState({ selectedTextGuild: guildId, selectedTextChannel: "" })}
onChannelChange={(channelId) => patchUIState({ selectedTextChannel: channelId })}
onReanalyze={messages.reanalyze}
/>
</TabsContent>
<TabsContent value="review">
<ReviewPanel messages={messages.messages} onReanalyze={messages.reanalyze} />
</TabsContent>
</Tabs>
</DashboardLayout>
); );
} }

8
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,8 @@
import { request } from "./client";
export async function login(password: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
});
}

View File

@@ -49,9 +49,13 @@ class ApiError extends Error {
} }
} }
async function request<T>(path: string, init?: RequestInit): Promise<T> { export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const password = localStorage.getItem("admin-password");
const res = await fetch(path, { const res = await fetch(path, {
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
...(password ? { "X-Admin-Password": password } : {}),
},
...init, ...init,
}); });

28
frontend/src/api/media.ts Normal file
View File

@@ -0,0 +1,28 @@
import { request } from "./client";
import type { MediaMode, MediaState } from "../types/media";
export function getMediaStatus(): Promise<MediaState> {
return request<MediaState>('/api/media/status');
}
export function queueMedia(source: string, mode: MediaMode): Promise<MediaState> {
return request<MediaState>('/api/media/queue', {
method: 'POST',
body: JSON.stringify({ source, mode }),
});
}
export function skipMedia(): Promise<MediaState> {
return request<MediaState>('/api/media/skip', { method: 'POST' });
}
export function stopMedia(): Promise<MediaState> {
return request<MediaState>('/api/media/stop', { method: 'POST' });
}
export function setMediaVolume(volume: number): Promise<MediaState> {
return request<MediaState>('/api/media/volume', {
method: 'POST',
body: JSON.stringify({ volume }),
});
}

View File

@@ -0,0 +1,3 @@
import { listMessages, listReview, reanalyzeMessage } from "./client";
export { listMessages, listReview, reanalyzeMessage };

View File

@@ -0,0 +1,13 @@
import { request } from "./client";
import type { UIState } from "../types/ui";
export function getUIState(): Promise<UIState> {
return request<UIState>('/api/ui-state');
}
export function updateUIState(patch: Partial<UIState>): Promise<UIState> {
return request<UIState>('/api/ui-state', {
method: 'POST',
body: JSON.stringify(patch),
});
}

29
frontend/src/api/voice.ts Normal file
View File

@@ -0,0 +1,29 @@
import { request } from "./client";
import type { Channel, Guild, VoiceStatus } from "../types/voice";
export function getGuilds(): Promise<Guild[]> {
return request<Guild[]>('/api/guilds');
}
export function getVoiceChannels(guildId: string): Promise<Channel[]> {
return request<Channel[]>(`/api/guilds/${guildId}/voice-channels`);
}
export function getTextChannels(guildId: string): Promise<Channel[]> {
return request<Channel[]>(`/api/guilds/${guildId}/channels`);
}
export function getVoiceStatus(): Promise<VoiceStatus> {
return request<VoiceStatus>('/api/status');
}
export function connectVoice(guildId: string, channelId: string): Promise<VoiceStatus> {
return request<VoiceStatus>('/api/connect', {
method: 'POST',
body: JSON.stringify({ guildId, channelId }),
});
}
export function disconnectVoice(): Promise<VoiceStatus> {
return request<VoiceStatus>('/api/disconnect', { method: 'POST' });
}

View File

@@ -0,0 +1,62 @@
import { useState } from "react";
import { login } from "../../api/auth";
import { Button } from "../ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Input } from "../ui/input";
import { Lock } from "lucide-react";
interface AuthOverlayProps {
onAuthenticated: () => void;
}
export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
await login(password);
localStorage.setItem("admin-password", password);
onAuthenticated();
} catch (err) {
setError("Invalid password");
} finally {
setLoading(false);
}
};
return (
<div className="flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Lock className="h-6 w-6" />
</div>
<CardTitle>Admin Access Required</CardTitle>
<CardDescription>Enter the admin password to access Voice and Media controls.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Input
type="password"
placeholder="Enter password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<Button type="submit" className="w-full" disabled={loading || !password}>
{loading ? "Authenticating..." : "Unlock Controls"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import type { DashboardTab } from "../../types/ui";
import type { VoiceStatus } from "../../types/voice";
import type { WebSocketStatus } from "../../hooks/useDashboardSocket";
import { Header } from "./Header";
import { Sidebar } from "./Sidebar";
interface DashboardLayoutProps {
activeTab: DashboardTab;
wsStatus: WebSocketStatus;
voiceStatus: VoiceStatus;
onTabChange: (tab: DashboardTab) => void;
children: ReactNode;
}
export function DashboardLayout({ activeTab, wsStatus, voiceStatus, onTabChange, children }: DashboardLayoutProps) {
return (
<div className="min-h-screen bg-background text-foreground">
<div className="flex min-h-screen">
<Sidebar activeTab={activeTab} onTabChange={onTabChange} />
<main className="flex min-w-0 flex-1 flex-col">
<Header activeTab={activeTab} wsStatus={wsStatus} voiceStatus={voiceStatus} />
<div className="flex-1 overflow-auto p-4 md:p-6 lg:p-8">{children}</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { Wifi, WifiOff } from "lucide-react";
import type { WebSocketStatus } from "../../hooks/useDashboardSocket";
import type { DashboardTab } from "../../types/ui";
import type { VoiceStatus } from "../../types/voice";
import { Badge } from "../ui/badge";
const titles: Record<DashboardTab, string> = {
voice: "Voice Control",
media: "Media Player",
messages: "Messages",
review: "Moderation Review",
};
interface HeaderProps {
activeTab: DashboardTab;
wsStatus: WebSocketStatus;
voiceStatus: VoiceStatus;
}
export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
return (
<header className="sticky top-0 z-10 border-b border-border bg-background/80 px-4 py-4 backdrop-blur md:px-8">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">{titles[activeTab]}</h1>
<p className="text-sm text-muted-foreground">Voice, media, and moderation in one dashboard.</p>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={wsStatus === "connected" ? "success" : wsStatus === "error" ? "destructive" : "warning"}>
{wsStatus === "connected" ? <Wifi className="mr-1 h-3 w-3" /> : <WifiOff className="mr-1 h-3 w-3" />}
WebSocket {wsStatus}
</Badge>
<Badge variant={voiceStatus.connected ? "success" : "secondary"}>
Voice {voiceStatus.connected ? voiceStatus.activeChannelName || "connected" : "idle"}
</Badge>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,48 @@
import { Bot, MessageSquare, Music2, ShieldAlert, Volume2 } from "lucide-react";
import type { DashboardTab } from "../../types/ui";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Volume2 }> = [
{ id: "voice", label: "Voice", icon: Volume2 },
{ id: "media", label: "Media", icon: Music2 },
{ id: "messages", label: "Messages", icon: MessageSquare },
{ id: "review", label: "Review", icon: ShieldAlert },
];
interface SidebarProps {
activeTab: DashboardTab;
onTabChange: (tab: DashboardTab) => void;
}
export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
return (
<aside className="hidden w-72 shrink-0 border-r border-border bg-card/60 p-5 backdrop-blur md:block">
<div className="mb-8 flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/15 text-primary">
<Bot className="h-6 w-6" />
</div>
<div>
<div className="font-semibold tracking-tight">Bete Watcher</div>
<div className="text-xs text-muted-foreground">Discord control center</div>
</div>
</div>
<nav className="space-y-2">
{navItems.map((item) => {
const Icon = item.icon;
return (
<Button
key={item.id}
variant={activeTab === item.id ? "secondary" : "ghost"}
className={cn("w-full justify-start", activeTab === item.id && "bg-primary/15 text-primary")}
onClick={() => onTabChange(item.id)}
>
<Icon className="h-4 w-4" />
{item.label}
</Button>
);
})}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,50 @@
import type { MediaState } from "../../types/media";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { MediaQueue } from "./MediaQueue";
import { MusicPlayer } from "./MusicPlayer";
import { ScreenShare } from "./ScreenShare";
interface MediaPanelProps {
state: MediaState;
loading: boolean;
onQueueMusic: (source: string) => void;
onStartScreen: (source: string) => void;
onSkip: () => void;
onStop: () => void;
onVolumeChange: (volume: number) => void;
}
export function MediaPanel({
state,
loading,
onQueueMusic,
onStartScreen,
onSkip,
onStop,
onVolumeChange,
}: MediaPanelProps) {
return (
<div className="grid gap-6 xl:grid-cols-[1fr_380px]">
<Tabs defaultValue="music" className="min-w-0">
<TabsList>
<TabsTrigger value="music">Music</TabsTrigger>
<TabsTrigger value="screen">Screen Share</TabsTrigger>
</TabsList>
<TabsContent value="music">
<MusicPlayer
loading={loading}
volume={state.musicVolume}
onVolumeChange={onVolumeChange}
onQueue={onQueueMusic}
onSkip={onSkip}
onStop={onStop}
/>
</TabsContent>
<TabsContent value="screen">
<ScreenShare loading={loading} onStart={onStartScreen} onSkip={onSkip} onStop={onStop} />
</TabsContent>
</Tabs>
<MediaQueue state={state} />
</div>
);
}

View File

@@ -0,0 +1,46 @@
import type { MediaState } from "../../types/media";
import { Badge } from "../ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
interface MediaQueueProps {
state: MediaState;
}
export function MediaQueue({ state }: MediaQueueProps) {
return (
<Card>
<CardHeader>
<CardTitle>Now Playing</CardTitle>
<CardDescription>Current item and queue state.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{state.current ? (
<div className="rounded-xl border border-primary/30 bg-primary/10 p-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">{state.current.title}</div>
<div className="truncate text-xs text-muted-foreground">{state.current.source}</div>
</div>
<Badge variant={state.current.mode === "screen" ? "warning" : "success"}>{state.current.mode || "music"}</Badge>
</div>
</div>
) : (
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">No media playing.</div>
)}
<div className="space-y-2">
<div className="text-sm font-medium">Queue</div>
{state.queue.length === 0 ? (
<div className="text-sm text-muted-foreground">Queue is empty.</div>
) : (
state.queue.map((item, index) => (
<div key={`${item.source}-${index}`} className="rounded-lg border border-border bg-background/60 p-3 text-sm">
<div className="font-medium">{item.title}</div>
<div className="truncate text-xs text-muted-foreground">{item.source}</div>
</div>
))
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,84 @@
import { Music2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "../ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Input } from "../ui/input";
interface MusicPlayerProps {
loading: boolean;
volume: number;
onVolumeChange: (volume: number) => void;
onQueue: (source: string) => void;
onSkip: () => void;
onStop: () => void;
}
export function MusicPlayer({
loading,
volume,
onVolumeChange,
onQueue,
onSkip,
onStop,
}: MusicPlayerProps) {
const [source, setSource] = useState("");
const safeVolume = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
useEffect(() => {
setDraftVolume(Math.round(safeVolume * 100));
}, [safeVolume]);
useEffect(() => {
const normalized = draftVolume / 100;
if (Math.abs(normalized - safeVolume) < 0.001) return;
const timer = window.setTimeout(() => {
onVolumeChange(normalized);
}, 150);
return () => window.clearTimeout(timer);
}, [draftVolume, onVolumeChange, safeVolume]);
const submit = () => {
const trimmed = source.trim();
if (!trimmed) return;
onQueue(trimmed);
setSource("");
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Music2 className="h-5 w-5" /> Music Player</CardTitle>
<CardDescription>Play YouTube, Spotify tracks, search terms, or local files as audio.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
value={source}
onChange={(event) => setSource(event.target.value)}
onKeyDown={(event) => event.key === "Enter" && submit()}
placeholder="YouTube URL, Spotify track, or search terms"
/>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Volume</span>
<span className="text-muted-foreground">{draftVolume}%</span>
</div>
<input
type="range"
min={0}
max={100}
step={1}
value={draftVolume}
onChange={(event) => setDraftVolume(Number(event.target.value))}
className="h-2 w-full cursor-pointer accent-primary"
/>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={loading || !source.trim()} onClick={submit}>Queue / Play</Button>
<Button variant="secondary" disabled={loading} onClick={onSkip}>Skip</Button>
<Button variant="destructive" disabled={loading} onClick={onStop}>Stop</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,45 @@
import { MonitorUp } from "lucide-react";
import { useState } from "react";
import { Button } from "../ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Input } from "../ui/input";
interface ScreenShareProps {
loading: boolean;
onStart: (source: string) => void;
onSkip: () => void;
onStop: () => void;
}
export function ScreenShare({ loading, onStart, onSkip, onStop }: ScreenShareProps) {
const [source, setSource] = useState("");
const submit = () => {
const trimmed = source.trim();
if (!trimmed) return;
onStart(trimmed);
setSource("");
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><MonitorUp className="h-5 w-5" /> Screen Share</CardTitle>
<CardDescription>Start screen-share playback from a URL or local file path.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
value={source}
onChange={(event) => setSource(event.target.value)}
onKeyDown={(event) => event.key === "Enter" && submit()}
placeholder="Screen share URL or local file path"
/>
<div className="flex flex-wrap gap-2">
<Button disabled={loading || !source.trim()} onClick={submit}>Start Screen Share</Button>
<Button variant="secondary" disabled={loading} onClick={onSkip}>Skip</Button>
<Button variant="destructive" disabled={loading} onClick={onStop}>Stop</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,43 @@
import type { MessageMetadata, MessageRecord } from "../../types/messages";
function parseMetadata(value: string | null): MessageMetadata {
if (!value) return {};
try {
return JSON.parse(value) as MessageMetadata;
} catch {
return {};
}
}
export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
const images = messages.flatMap((message) => {
const metadata = parseMetadata(message.metadata);
const attachments = metadata.attachments ?? [];
const embeds = metadata.embeds ?? [];
return [
...attachments
.filter((attachment) => attachment.url && (attachment.contentType?.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(attachment.name)))
.map((attachment) => ({ url: attachment.url, title: attachment.name, message })),
...embeds
.flatMap((embed) => [embed.image, embed.thumbnail].filter(Boolean).map((url) => ({ url: url as string, title: embed.title || "embed image", message }))),
];
});
if (images.length === 0) {
return <div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">No images found.</div>;
}
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{images.map((image, index) => (
<a key={`${image.url}-${index}`} href={image.url} target="_blank" rel="noreferrer" className="group overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
<img src={image.url} alt={image.title} className="aspect-video w-full object-cover transition-transform group-hover:scale-105" />
<div className="p-3">
<div className="truncate text-sm font-medium">{image.title}</div>
<div className="truncate text-xs text-muted-foreground">{image.message.username}</div>
</div>
</a>
))}
</div>
);
}

View File

@@ -1,74 +1,51 @@
import type { MessageRecord } from "../../api/client"; import { RotateCw } from "lucide-react";
import type { MessageRecord } from "../../types/messages";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
export interface MessageCardProps { export interface MessageCardProps {
message: MessageRecord; message: MessageRecord;
onReanalyze: (id: string) => void; onReanalyze: (id: string) => void;
} }
const STATUS_COLORS: Record<string, string> = { function aiVariant(status: string) {
pending: "#f9e2af", if (status === "clean") return "success";
clean: "#a6e3a1", if (status === "warn") return "warning";
warn: "#fab387", if (status === "flagged" || status === "error") return "destructive";
flagged: "#f38ba8", return "secondary";
error: "#f38ba8", }
};
export function MessageCard({ message, onReanalyze }: MessageCardProps) { export function MessageCard({ message, onReanalyze }: MessageCardProps) {
const displayContent = message.edited_content ?? message.content; const displayContent = message.edited_content ?? message.content;
const aiStatus = message.ai_status ?? "pending"; const aiStatus = message.ai_status ?? "pending";
const statusColor = STATUS_COLORS[aiStatus] ?? "#6c7086";
return ( return (
<div className={`message-card type-${message.type}`}> <article className="rounded-2xl border border-border bg-card p-4 shadow-sm">
<img <div className="flex gap-3">
src={message.avatar_url ?? "/default-avatar.png"} <img
alt={message.username} src={message.avatar_url ?? "https://cdn.discordapp.com/embed/avatars/0.png"}
className="message-card-avatar" alt=""
width={32} className="h-10 w-10 rounded-full object-cover"
height={32} />
/> <div className="min-w-0 flex-1 space-y-3">
<div className="message-card-body"> <div className="flex flex-wrap items-center gap-2">
<div className="message-card-meta"> <span className="font-medium">{message.username || message.user_id}</span>
<span className="message-card-username">{message.username}</span> <span className="text-xs text-muted-foreground">{new Date(message.created_at).toLocaleString()}</span>
<span className="message-card-time"> {message.edited_at ? <Badge variant="outline">edited</Badge> : null}
{new Date(message.created_at).toLocaleString()} {message.deleted_at ? <Badge variant="destructive">deleted</Badge> : null}
</span> <Badge variant={aiVariant(aiStatus)}>{aiStatus}</Badge>
{message.type === "edited" && ( </div>
<span className="badge badge-edited">edited</span> <p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
)} {displayContent || "(empty message)"}
{message.type === "deleted" && ( </p>
<span className="badge badge-deleted">deleted</span> {message.ai_analysis ? <div className="rounded-xl bg-muted p-3 text-sm text-muted-foreground">{message.ai_analysis}</div> : null}
)} {message.ai_error ? <div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">AI error: {message.ai_error}</div> : null}
<span <Button size="sm" variant="outline" onClick={() => onReanalyze(message.id)} disabled={aiStatus === "pending"}>
className="badge badge-ai" <RotateCw className="h-3.5 w-3.5" />
style={{ backgroundColor: statusColor }} Re-analyze
title={`AI: ${aiStatus}`} </Button>
>
{aiStatus}
</span>
</div>
<p className="message-card-content">{displayContent}</p>
{message.ai_analysis && (
<div className="message-card-analysis">{message.ai_analysis}</div>
)}
{message.ai_error && (
<div className="message-card-error">{message.ai_error}</div>
)}
<div className="message-card-actions">
<button
type="button"
className="btn-reanalyze"
onClick={() => onReanalyze(message.id)}
disabled={aiStatus === "pending"}
>
Reanalyze
</button>
</div> </div>
</div> </div>
</div> </article>
); );
} }

View File

@@ -1,25 +1,25 @@
import type { MessageRecord } from "../../api/client"; import type { MessageRecord } from "../../types/messages";
import { ScrollArea } from "../ui/scroll-area";
import { MessageCard } from "./MessageCard"; import { MessageCard } from "./MessageCard";
export interface MessageFeedProps { export interface MessageFeedProps {
messages: MessageRecord[]; messages: MessageRecord[];
onReanalyze: (id: string) => void; onReanalyze: (id: string) => void;
emptyText?: string;
} }
export function MessageFeed({ messages, onReanalyze }: MessageFeedProps) { export function MessageFeed({ messages, onReanalyze, emptyText = "No messages found." }: MessageFeedProps) {
if (messages.length === 0) { if (messages.length === 0) {
return ( return <div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">{emptyText}</div>;
<div className="empty-state">
<p>No messages yet</p>
</div>
);
} }
return ( return (
<div className="message-feed"> <ScrollArea className="h-[calc(100vh-260px)] pr-3">
{messages.map((msg) => ( <div className="space-y-3">
<MessageCard key={msg.id} message={msg} onReanalyze={onReanalyze} /> {messages.map((message) => (
))} <MessageCard key={message.id} message={message} onReanalyze={onReanalyze} />
</div> ))}
</div>
</ScrollArea>
); );
} }

View File

@@ -0,0 +1,66 @@
import type { Channel, Guild } from "../../types/voice";
import type { MessageRecord } from "../../types/messages";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Select } from "../ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { ImageGrid } from "./ImageGrid";
import { MessageFeed } from "./MessageFeed";
interface MessagesPanelProps {
guilds: Guild[];
channels: Channel[];
selectedGuild: string;
selectedChannel: string;
messages: MessageRecord[];
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
onReanalyze: (id: string) => void;
}
export function MessagesPanel({
guilds,
channels,
selectedGuild,
selectedChannel,
messages,
onGuildChange,
onChannelChange,
onReanalyze,
}: MessagesPanelProps) {
return (
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Message Source</CardTitle>
<CardDescription>Pick a guild and channel/thread to inspect captures.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<Select
value={selectedGuild}
onChange={(event) => onGuildChange(event.target.value)}
placeholder="Select text guild"
options={guilds.map((guild) => ({ value: guild.id, label: guild.name }))}
/>
<Select
value={selectedChannel}
onChange={(event) => onChannelChange(event.target.value)}
placeholder="Select channel or thread"
options={channels.map((channel) => ({ value: channel.id, label: channel.name }))}
/>
</CardContent>
</Card>
<Tabs defaultValue="all">
<TabsList>
<TabsTrigger value="all">All Messages</TabsTrigger>
<TabsTrigger value="images">Images</TabsTrigger>
</TabsList>
<TabsContent value="all">
<MessageFeed messages={messages} onReanalyze={onReanalyze} emptyText={selectedChannel ? "No captures yet." : "Select a channel to view captures."} />
</TabsContent>
<TabsContent value="images">
<ImageGrid messages={messages} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import type { MessageRecord } from "../../api/client"; import type { MessageRecord } from "../../types/messages";
import { MessageCard } from "../messages/MessageCard"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { MessageFeed } from "../messages/MessageFeed";
export interface ReviewPanelProps { export interface ReviewPanelProps {
messages: MessageRecord[]; messages: MessageRecord[];
@@ -8,30 +9,21 @@ export interface ReviewPanelProps {
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) { export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
const reviewItems = messages.filter( const reviewItems = messages.filter(
(m) => (message) =>
m.ai_status === "warn" || message.ai_status === "warn" ||
m.ai_status === "flagged" || message.ai_status === "flagged" ||
m.ai_status === "error", message.ai_status === "error",
); );
return ( return (
<div className="review-panel"> <Card>
<div className="review-header"> <CardHeader>
<h2>Needs Review</h2> <CardTitle>Needs Review</CardTitle>
<span className="review-count">{reviewItems.length}</span> <CardDescription>{reviewItems.length} captured messages require attention.</CardDescription>
</div> </CardHeader>
<CardContent>
{reviewItems.length === 0 ? ( <MessageFeed messages={reviewItems} onReanalyze={onReanalyze} emptyText="No warned, flagged, or errored messages." />
<div className="empty-state"> </CardContent>
<p>No items to review</p> </Card>
</div>
) : (
<div className="review-list">
{reviewItems.map((msg) => (
<MessageCard key={msg.id} message={msg} onReanalyze={onReanalyze} />
))}
</div>
)}
</div>
); );
} }

View File

@@ -0,0 +1,30 @@
import type * as React from "react";
import { cn } from "../../lib/utils";
type BadgeVariant = "default" | "secondary" | "destructive" | "outline" | "success" | "warning";
const variants: Record<BadgeVariant, string> = {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
success: "border-transparent bg-emerald-500/15 text-emerald-300",
warning: "border-transparent bg-amber-500/15 text-amber-300",
};
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: BadgeVariant;
}
export function Badge({ className, variant = "default", ...props }: BadgeProps) {
return (
<div
className={cn(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
variants[variant],
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,48 @@
import { Slot } from "@radix-ui/react-slot";
import type * as React from "react";
import { cn } from "../../lib/utils";
type ButtonVariant = "default" | "secondary" | "destructive" | "outline" | "ghost";
type ButtonSize = "default" | "sm" | "lg" | "icon";
const variants: Record<ButtonVariant, string> = {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
};
const sizes: Record<ButtonSize, string> = {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
};
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
variant?: ButtonVariant;
size?: ButtonSize;
}
export function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
variants[variant],
sizes[size],
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,26 @@
import type * as React from "react";
import { cn } from "../../lib/utils";
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("rounded-2xl border border-border bg-card text-card-foreground shadow-sm", className)} {...props} />;
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", className)} {...props} />;
}
export function CardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex items-center p-6 pt-0", className)} {...props} />;
}

View File

@@ -0,0 +1,15 @@
import type * as React from "react";
import { cn } from "../../lib/utils";
export function Input({ className, type, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,30 @@
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import type * as React from "react";
import { cn } from "../../lib/utils";
export function ScrollArea({ className, children, ...props }: React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({ className, orientation = "vertical", ...props }: React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}

View File

@@ -0,0 +1,31 @@
import type * as React from "react";
import { cn } from "../../lib/utils";
export interface SelectOption {
value: string;
label: string;
}
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
options: SelectOption[];
placeholder?: string;
}
export function Select({ className, options, placeholder, ...props }: SelectProps) {
return (
<select
className={cn(
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
{placeholder ? <option value="">{placeholder}</option> : null}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,35 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import type * as React from "react";
import { cn } from "../../lib/utils";
export const Tabs = TabsPrimitive.Root;
export function TabsList({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
className={cn("inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", className)}
{...props}
/>
);
}
export function TabsTrigger({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
);
}
export function TabsContent({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
className={cn("mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className)}
{...props}
/>
);
}

View File

@@ -0,0 +1,35 @@
import type { ActiveSpeaker } from "../../types/voice";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
interface ActiveSpeakersProps {
speakers: ActiveSpeaker[];
}
export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
return (
<Card>
<CardHeader>
<CardTitle>Active Speakers</CardTitle>
</CardHeader>
<CardContent>
{speakers.length === 0 ? (
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
No active speakers.
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{speakers.map((speaker, index) => (
<div key={speaker.userId || speaker.id || index} className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3">
<img src={speaker.avatar} alt="" className="h-10 w-10 rounded-full object-cover" />
<div className="min-w-0">
<div className="truncate text-sm font-medium">{speaker.username}</div>
<div className="text-xs text-emerald-300">Speaking</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,18 @@
interface AudioVisualizerProps {
levels: number[];
}
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
const bars = levels.length ? levels : Array.from({ length: 32 }, () => 0.04);
return (
<div className="flex h-40 items-end gap-1 rounded-2xl border border-border bg-background/60 p-4">
{bars.map((level, index) => (
<div
key={`${index}-${level}`}
className="flex-1 rounded-full bg-gradient-to-t from-primary/50 to-cyan-300 transition-all duration-150"
style={{ height: `${Math.max(6, Math.min(100, level * 100))}%` }}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,83 @@
import type { Channel, Guild, VoiceStatus } from "../../types/voice";
import { Button } from "../ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Select } from "../ui/select";
interface VoiceControlProps {
guilds: Guild[];
channels: Channel[];
selectedGuild: string;
selectedChannel: string;
status: VoiceStatus;
loading: boolean;
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
onJoin: () => void;
onDisconnect: () => void;
onListenToggle: () => void;
onStreamingToggle: () => void;
isListening: boolean;
isStreaming: boolean;
}
export function VoiceControl({
guilds,
channels,
selectedGuild,
selectedChannel,
status,
loading,
onGuildChange,
onChannelChange,
onJoin,
onDisconnect,
onListenToggle,
onStreamingToggle,
isListening,
isStreaming,
}: VoiceControlProps) {
return (
<Card>
<CardHeader>
<CardTitle>Voice Bridge</CardTitle>
<CardDescription>Join a Discord voice channel and monitor audio in real time.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Guild</label>
<Select
value={selectedGuild}
onChange={(event) => onGuildChange(event.target.value)}
placeholder="Select guild"
options={guilds.map((guild) => ({ value: guild.id, label: guild.name }))}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Voice Channel</label>
<Select
value={selectedChannel}
onChange={(event) => onChannelChange(event.target.value)}
placeholder="Select voice channel"
options={channels.map((channel) => ({ value: channel.id, label: channel.name }))}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={!selectedGuild || !selectedChannel || loading} onClick={onJoin}>
{status.connected ? "Reconnect" : "Join Voice"}
</Button>
<Button variant="destructive" disabled={!status.connected || loading} onClick={onDisconnect}>
Disconnect
</Button>
<Button variant={isListening ? "secondary" : "outline"} onClick={onListenToggle}>
{isListening ? "Stop Listening" : "Listen Live"}
</Button>
<Button variant={isStreaming ? "destructive" : "default"} onClick={onStreamingToggle}>
{isStreaming ? "Stop Transmitting" : "Start Transmitting"}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,43 @@
import type { ActiveSpeaker, Channel, Guild, VoiceStatus } from "../../types/voice";
import { AudioVisualizer } from "./AudioVisualizer";
import { ActiveSpeakers } from "./ActiveSpeakers";
import { VoiceControl } from "./VoiceControl";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
interface VoicePanelProps {
guilds: Guild[];
channels: Channel[];
selectedGuild: string;
selectedChannel: string;
status: VoiceStatus;
loading: boolean;
activeSpeakers: ActiveSpeaker[];
levels: number[];
isListening: boolean;
isStreaming: boolean;
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
onJoin: () => void;
onDisconnect: () => void;
onListenToggle: () => void;
onStreamingToggle: () => void;
}
export function VoicePanel(props: VoicePanelProps) {
return (
<div className="grid gap-6">
<VoiceControl {...props} />
<div className="grid gap-6 xl:grid-cols-[1fr_360px]">
<Card>
<CardHeader>
<CardTitle>Live Audio Visualizer</CardTitle>
</CardHeader>
<CardContent>
<AudioVisualizer levels={props.levels} />
</CardContent>
</Card>
<ActiveSpeakers speakers={props.activeSpeakers} />
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { useEffect, useRef, useState } from "react";
import type { MessageRecord } from "../types/messages";
import type { MediaState } from "../types/media";
import type { UIState } from "../types/ui";
import type { ActiveSpeaker } from "../types/voice";
export type WebSocketStatus = "connecting" | "connected" | "disconnected" | "error";
export interface DashboardSocketHandlers {
onUIState?: (state: UIState) => void;
onUserState?: (users: ActiveSpeaker[]) => void;
onMessageCreated?: (message: MessageRecord) => void;
onMessageUpdated?: (message: Partial<MessageRecord> & { id: string }) => void;
onMessageDeleted?: (message: { id: string }) => void;
onMessageAnalyzed?: (message: MessageRecord) => void;
onAttachmentUploaded?: () => void;
onMediaState?: (state: MediaState) => void;
onPcm?: (data: ArrayBuffer) => void;
}
export function useDashboardSocket(handlers: DashboardSocketHandlers) {
const [status, setStatus] = useState<WebSocketStatus>("connecting");
const handlersRef = useRef(handlers);
const socketRef = useRef<WebSocket | null>(null);
handlersRef.current = handlers;
useEffect(() => {
let closed = false;
let reconnectTimer: number | null = null;
const connect = () => {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${location.host}/ws`);
socket.binaryType = "arraybuffer";
socketRef.current = socket;
setStatus("connecting");
socket.addEventListener("open", () => setStatus("connected"));
socket.addEventListener("error", () => setStatus("error"));
socket.addEventListener("close", () => {
setStatus("disconnected");
if (!closed) reconnectTimer = window.setTimeout(connect, 2500);
});
socket.addEventListener("message", (event) => {
if (event.data instanceof ArrayBuffer) {
handlersRef.current.onPcm?.(event.data);
return;
}
if (typeof event.data !== "string") return;
try {
const message = JSON.parse(event.data);
switch (message.type) {
case "ui_state":
handlersRef.current.onUIState?.(message.state);
break;
case "user_state":
handlersRef.current.onUserState?.(message.users || []);
break;
case "message_created":
handlersRef.current.onMessageCreated?.(message.data);
break;
case "message_updated":
handlersRef.current.onMessageUpdated?.(message.data);
break;
case "message_deleted":
handlersRef.current.onMessageDeleted?.(message.data);
break;
case "message_analyzed":
handlersRef.current.onMessageAnalyzed?.(message.data);
break;
case "attachment_uploaded":
handlersRef.current.onAttachmentUploaded?.();
break;
case "media_state":
handlersRef.current.onMediaState?.(message.state);
break;
}
} catch {
// ignore malformed socket messages
}
});
};
connect();
return () => {
closed = true;
if (reconnectTimer) window.clearTimeout(reconnectTimer);
socketRef.current?.close();
socketRef.current = null;
};
}, []);
return { status, socketRef };
}

View File

@@ -0,0 +1,97 @@
import { useCallback, useEffect, useState } from "react";
import {
getMediaStatus,
queueMedia,
setMediaVolume,
skipMedia,
stopMedia,
} from "../api/media";
import type { MediaMode, MediaState } from "../types/media";
const emptyMediaState: MediaState = {
playing: false,
musicVolume: 1,
current: null,
queue: [],
};
export function useMediaControl() {
const [mediaState, setMediaState] = useState<MediaState>(emptyMediaState);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refreshMedia = useCallback(async () => {
const state = await getMediaStatus();
setMediaState(state);
return state;
}, []);
const enqueue = useCallback(async (source: string, mode: MediaMode) => {
setLoading(true);
setError(null);
try {
const state = await queueMedia(source, mode);
setMediaState(state);
return state;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
const skip = useCallback(async () => {
setLoading(true);
setError(null);
try {
const state = await skipMedia();
setMediaState(state);
return state;
} finally {
setLoading(false);
}
}, []);
const stop = useCallback(async () => {
setLoading(true);
setError(null);
try {
const state = await stopMedia();
setMediaState(state);
return state;
} finally {
setLoading(false);
}
}, []);
const setVolume = useCallback(async (volume: number) => {
setError(null);
try {
const state = await setMediaVolume(volume);
setMediaState(state);
return state;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
throw err;
}
}, []);
useEffect(() => {
refreshMedia().catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [refreshMedia]);
return {
mediaState,
setMediaState,
loading,
error,
refreshMedia,
enqueue,
skip,
stop,
setVolume,
};
}

View File

@@ -0,0 +1,58 @@
import { useCallback, useEffect, useState } from "react";
import { listMessages, reanalyzeMessage } from "../api/messages";
import type { MessageRecord } from "../types/messages";
export function mergeMessages(current: MessageRecord[], incoming: MessageRecord[]): MessageRecord[] {
const byId = new Map(current.map((message) => [message.id, message]));
for (const message of incoming) {
byId.set(message.id, { ...byId.get(message.id), ...message });
}
return Array.from(byId.values())
.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
.slice(0, 200);
}
export function useMessages() {
const [messages, setMessages] = useState<MessageRecord[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchMessages = useCallback(async (channelId?: string) => {
if (!channelId) {
setMessages([]);
return [];
}
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ limit: "80" });
params.set("channel", channelId);
const result = await listMessages(params);
setMessages(result.data);
return result.data;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
const reanalyze = useCallback(async (id: string) => {
setMessages((prev) =>
prev.map((message) =>
message.id === id
? { ...message, ai_status: "pending", ai_error: null, ai_analysis: null }
: message,
),
);
await reanalyzeMessage(id);
}, []);
useEffect(() => {
fetchMessages().catch(() => undefined);
}, [fetchMessages]);
return { messages, setMessages, loading, error, fetchMessages, reanalyze };
}

View File

@@ -0,0 +1,35 @@
import { useCallback, useEffect, useState } from "react";
import { getUIState, updateUIState } from "../api/uiState";
import type { UIState } from "../types/ui";
export function useUIState() {
const [uiState, setUIState] = useState<UIState>({ activeTab: "voice" });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getUIState()
.then((state) => {
if (!cancelled) setUIState({ activeTab: "voice", ...state });
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const patchUIState = useCallback(async (patch: Partial<UIState>) => {
setUIState((prev) => ({ ...prev, ...patch }));
const next = await updateUIState(patch);
setUIState((prev) => ({ ...prev, ...next }));
return next;
}, []);
return { uiState, setUIState, patchUIState, loading, error };
}

View File

@@ -0,0 +1,104 @@
import { useCallback, useEffect, useState } from "react";
import {
connectVoice,
disconnectVoice,
getGuilds,
getTextChannels,
getVoiceChannels,
getVoiceStatus,
} from "../api/voice";
import type { Channel, Guild, VoiceStatus } from "../types/voice";
export function useVoiceControl() {
const [guilds, setGuilds] = useState<Guild[]>([]);
const [voiceChannels, setVoiceChannels] = useState<Channel[]>([]);
const [textChannels, setTextChannels] = useState<Channel[]>([]);
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>({ connected: false });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refreshGuilds = useCallback(async () => {
setError(null);
const nextGuilds = await getGuilds();
setGuilds(nextGuilds);
return nextGuilds;
}, []);
const refreshVoiceStatus = useCallback(async () => {
const status = await getVoiceStatus();
setVoiceStatus(status);
return status;
}, []);
const loadVoiceChannels = useCallback(async (guildId: string) => {
if (!guildId) {
setVoiceChannels([]);
return [];
}
const channels = await getVoiceChannels(guildId);
setVoiceChannels(channels);
return channels;
}, []);
const loadTextTargets = useCallback(async (guildId: string) => {
if (!guildId) {
setTextChannels([]);
return [];
}
const channels = await getTextChannels(guildId);
setTextChannels(channels);
return channels;
}, []);
const joinVoice = useCallback(async (guildId: string, channelId: string) => {
setLoading(true);
setError(null);
try {
const status = await connectVoice(guildId, channelId);
setVoiceStatus(status);
return status;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
const leaveVoice = useCallback(async () => {
setLoading(true);
setError(null);
try {
const status = await disconnectVoice();
setVoiceStatus(status);
return status;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refreshGuilds().catch((err) => setError(err instanceof Error ? err.message : String(err)));
refreshVoiceStatus().catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [refreshGuilds, refreshVoiceStatus]);
return {
guilds,
voiceChannels,
textChannels,
voiceStatus,
loading,
error,
refreshGuilds,
refreshVoiceStatus,
loadVoiceChannels,
loadTextTargets,
joinVoice,
leaveVoice,
};
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,341 +1,41 @@
* { @tailwind base;
box-sizing: border-box; @tailwind components;
margin: 0; @tailwind utilities;
padding: 0;
}
body { @layer base {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; :root {
background: #1e1e2e; --background: 222 47% 7%;
color: #cdd6f4; --foreground: 210 40% 98%;
height: 100vh; --card: 222 47% 10%;
overflow: hidden; --card-foreground: 210 40% 98%;
} --primary: 199 89% 48%;
--primary-foreground: 210 40% 98%;
--secondary: 217 33% 17%;
--secondary-foreground: 210 40% 98%;
--muted: 217 33% 17%;
--muted-foreground: 215 20% 65%;
--accent: 217 33% 17%;
--accent-foreground: 210 40% 98%;
--destructive: 0 72% 51%;
--destructive-foreground: 210 40% 98%;
--border: 217 33% 20%;
--input: 217 33% 20%;
--ring: 199 89% 48%;
--radius: 0.85rem;
}
#root { * {
height: 100%; @apply border-border;
} }
.app { body {
display: flex; @apply bg-background text-foreground antialiased;
height: 100vh; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
} }
.sidebar { html,
width: 220px; body,
background: #181825; #root {
border-right: 1px solid #313244; min-height: 100%;
display: flex; }
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 16px;
font-size: 14px;
font-weight: 700;
color: #cdd6f4;
border-bottom: 1px solid #313244;
}
.sidebar-placeholder {
padding: 16px;
font-size: 12px;
color: #6c7086;
flex: 1;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid #313244;
background: #181825;
flex-shrink: 0;
}
.header h1 {
font-size: 16px;
font-weight: 600;
color: #cdd6f4;
}
.ws-status {
font-size: 11px;
padding: 3px 8px;
border-radius: 4px;
font-weight: 500;
}
.ws-status[data-status="connected"] {
background: #a6e3a1;
color: #1e1e2e;
}
.ws-status[data-status="disconnected"],
.ws-status[data-status="error"] {
background: #f38ba8;
color: #1e1e2e;
}
.ws-status[data-status="connecting"] {
background: #f9e2af;
color: #1e1e2e;
}
.content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.message-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.empty-state {
text-align: center;
color: #6c7086;
padding: 40px;
}
.message-item {
display: flex;
gap: 12px;
padding: 10px 12px;
background: #181825;
border-radius: 6px;
border: 1px solid #313244;
}
.message-item.type-deleted {
opacity: 0.6;
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
flex-shrink: 0;
}
.message-body {
flex: 1;
min-width: 0;
}
.message-username {
font-weight: 600;
color: #89b4fa;
margin-right: 8px;
}
.message-time {
font-size: 11px;
color: #6c7086;
}
.message-deleted {
font-size: 11px;
color: #f38ba8;
margin-left: 8px;
font-style: italic;
}
.message-content {
margin-top: 4px;
font-size: 13px;
color: #bac2de;
white-space: pre-wrap;
word-break: break-word;
}
.review-panel {
border-top: 1px solid #313244;
background: #181825;
flex-shrink: 0;
}
/* Message Card */
.message-card {
display: flex;
gap: 12px;
padding: 12px;
background: #181825;
border-radius: 6px;
border: 1px solid #313244;
}
.message-card.type-deleted {
opacity: 0.6;
}
.message-card-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
flex-shrink: 0;
}
.message-card-body {
flex: 1;
min-width: 0;
}
.message-card-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.message-card-username {
font-weight: 600;
color: #89b4fa;
}
.message-card-time {
font-size: 11px;
color: #6c7086;
}
.badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
color: #1e1e2e;
}
.badge-edited {
background: #fab387;
color: #1e1e2e;
}
.badge-deleted {
background: #f38ba8;
color: #1e1e2e;
}
.message-card-content {
margin-top: 6px;
font-size: 13px;
color: #bac2de;
white-space: pre-wrap;
word-break: break-word;
}
.message-card-analysis {
margin-top: 8px;
padding: 8px;
background: #1e1e2e;
border-radius: 4px;
font-size: 12px;
color: #a6e3a1;
white-space: pre-wrap;
}
.message-card-error {
margin-top: 8px;
padding: 8px;
background: #1e1e2e;
border-radius: 4px;
font-size: 12px;
color: #f38ba8;
}
.message-card-actions {
margin-top: 8px;
}
.btn-reanalyze {
padding: 4px 10px;
font-size: 11px;
border: none;
border-radius: 4px;
background: #313244;
color: #cdd6f4;
cursor: pointer;
}
.btn-reanalyze:hover:not(:disabled) {
background: #45475a;
}
.btn-reanalyze:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Message Feed */
.message-feed {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Review Panel */
.review-panel {
border-top: 1px solid #313244;
background: #181825;
flex-shrink: 0;
max-height: 250px;
overflow-y: auto;
}
.review-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid #313244;
}
.review-header h2 {
font-size: 13px;
font-weight: 600;
color: #cdd6f4;
}
.review-count {
font-size: 11px;
padding: 2px 6px;
border-radius: 10px;
background: #f38ba8;
color: #1e1e2e;
font-weight: 600;
}
.review-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
}
.review-placeholder {
padding: 16px;
font-size: 12px;
color: #6c7086;
}
.review-panel .empty-state {
padding: 16px;
}
/* Keep existing styles */
.review-placeholder {
padding: 16px;
font-size: 12px;
color: #6c7086;
} }

View File

@@ -0,0 +1,7 @@
export {};
declare global {
interface Window {
webkitAudioContext?: typeof AudioContext;
}
}

View File

@@ -0,0 +1,17 @@
export type MediaMode = "music" | "screen";
export interface MediaItem {
id?: string;
source: string;
title: string;
mode?: MediaMode;
durationMs?: number | null;
thumbnailUrl?: string | null;
}
export interface MediaState {
playing: boolean;
musicVolume: number;
current: MediaItem | null;
queue: MediaItem[];
}

View File

@@ -0,0 +1,29 @@
export type { AIStatus, MessageRecord, PageResult } from "../api/client";
export interface MessageMetadataAttachment {
name: string;
url: string;
size: number;
contentType?: string | null;
}
export interface MessageMetadataEmbed {
title?: string;
description?: string;
url?: string;
image?: string;
thumbnail?: string;
}
export interface MessageMetadataSticker {
name: string;
url: string;
}
export interface MessageMetadata {
attachments?: MessageMetadataAttachment[];
embeds?: MessageMetadataEmbed[];
stickers?: MessageMetadataSticker[];
reference?: { messageId?: string };
channel?: { threadName?: string };
}

12
frontend/src/types/ui.ts Normal file
View File

@@ -0,0 +1,12 @@
export type DashboardTab = "voice" | "media" | "messages" | "review";
export interface UIState {
selectedGuild?: string;
selectedVoiceGuild?: string;
selectedVoiceChannel?: string;
selectedTextGuild?: string;
selectedTextChannel?: string;
activeTab?: DashboardTab;
isListening?: boolean;
isStreaming?: boolean;
}

View File

@@ -0,0 +1,27 @@
export interface Guild {
id: string;
name: string;
icon?: string | null;
}
export interface Channel {
id: string;
name: string;
type?: string;
parentId?: string | null;
}
export interface VoiceStatus {
connected: boolean;
activeGuildId?: string | null;
activeChannelId?: string | null;
activeChannelName?: string | null;
}
export interface ActiveSpeaker {
id?: string;
userId?: string;
username: string;
avatar: string;
speaking: boolean;
}

View File

@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" },
secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" },
muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" },
destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" },
card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" },
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};

View File

@@ -2,6 +2,7 @@
"name": "discord-voice-recorder", "name": "discord-voice-recorder",
"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",
"type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"packageManager": "pnpm@10.25.0", "packageManager": "pnpm@10.25.0",
"scripts": { "scripts": {
@@ -18,31 +19,40 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:migrate:programmatic": "tsx src/database/migrate.ts", "db:migrate:programmatic": "tsx src/database/migrate.ts",
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio",
"install:yt-dlp": "sh scripts/install-yt-dlp.sh"
}, },
"dependencies": { "dependencies": {
"@dank074/discord-video-stream": "^6.0.0",
"@discordjs/opus": "^0.10.0", "@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.1", "@discordjs/voice": "^0.19.1",
"@dank074/discord-video-stream": "workspace:*", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@snazzah/davey": "^0.1.10", "@snazzah/davey": "^0.1.10",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"better-sqlite3": "^12.10.0", "better-sqlite3": "^12.10.0",
"clsx": "^2.1.1",
"discord.js-selfbot-v13": "workspace:*", "discord.js-selfbot-v13": "workspace:*",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"express": "^5.2.1", "express": "^5.2.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"libsodium-wrappers": "^0.8.2", "libsodium-wrappers": "^0.8.2",
"lucide-react": "^1.16.0",
"p-retry": "^8.0.0", "p-retry": "^8.0.0",
"pg": "^8.20.0", "pg": "^8.20.0",
"pino": "^10.3.1", "pino": "^10.3.1",
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"play-dl": "^1.9.7",
"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": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"sodium-native": "^5.1.0", "sodium-native": "^5.1.0",
"tailwind-merge": "^3.6.0",
"vite": "^8.0.13", "vite": "^8.0.13",
"ws": "^8.20.1", "ws": "^8.20.1",
"zod": "^4.4.3" "zod": "^4.4.3"
@@ -55,8 +65,11 @@
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"autoprefixer": "^10.5.0",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"postcss": "^8.5.14",
"tailwindcss": "^3.4.17",
"tsx": "^4.22.0", "tsx": "^4.22.0",
"vitest": "latest" "vitest": "latest"
} }

1886
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,12 @@
packages: packages:
- . - .
- vendor/discord.js-selfbot-v13 - vendor/discord.js-selfbot-v13
- vendor/Discord-video-stream
onlyBuiltDependencies: onlyBuiltDependencies:
- '@discordjs/opus' - '@discordjs/opus'
- '@lng2004/node-datachannel'
- better-sqlite3 - better-sqlite3
- esbuild - esbuild
- node-av
- sharp
- zeromq

5
postcss.config.js Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
autoprefixer: {},
},
};

View File

@@ -1,417 +0,0 @@
:root {
--bg: #080a0f;
--panel: rgba(18, 22, 32, 0.86);
--panel-strong: #121720;
--line: rgba(255, 255, 255, 0.12);
--text: #edf4ff;
--muted: #91a0b6;
--faint: #536176;
--cyan: #00e5ff;
--green: #39ff88;
--yellow: #ffe45e;
--red: #ff4f6d;
--blue: #6275ff;
--shadow: 0 24px 70px rgba(0, 0, 0, 0.46);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--text);
font-family: Manrope, sans-serif;
background:
radial-gradient(circle at 12% 8%, rgba(0, 229, 255, 0.18), transparent 32rem),
radial-gradient(circle at 88% 0%, rgba(98, 117, 255, 0.2), transparent 28rem),
linear-gradient(145deg, #05060a, var(--bg));
overflow-x: hidden;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(to bottom, black, transparent 84%);
}
button, select { font: inherit; }
.shell {
width: min(1440px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 44px;
}
.hero {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 18px;
align-items: stretch;
margin-bottom: 18px;
}
.brand-card,
.status-card,
.tab-panel,
.content-card {
border: 1px solid var(--line);
background: var(--panel);
backdrop-filter: blur(18px);
box-shadow: var(--shadow);
}
.brand-card {
position: relative;
padding: 28px;
border-radius: 28px;
overflow: hidden;
min-height: 190px;
}
.brand-card::after {
content: "WATCHER";
position: absolute;
right: -14px;
bottom: -20px;
font-family: "Archivo Black", sans-serif;
font-size: clamp(58px, 9vw, 132px);
letter-spacing: -0.08em;
color: rgba(255,255,255,0.035);
line-height: 0.78;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
color: var(--cyan);
font: 700 12px/1 "JetBrains Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.18em;
}
.pulse {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--green);
box-shadow: 0 0 20px var(--green);
}
h1 {
margin: 0;
max-width: 840px;
font-family: "Archivo Black", sans-serif;
font-size: clamp(40px, 6vw, 82px);
line-height: 0.88;
letter-spacing: -0.06em;
text-transform: uppercase;
}
.subtitle {
margin: 18px 0 0;
max-width: 720px;
color: var(--muted);
font-size: 15px;
line-height: 1.7;
}
.status-card {
border-radius: 28px;
padding: 24px;
display: grid;
gap: 14px;
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid var(--line);
}
.status-row:last-child { border-bottom: 0; }
.status-label {
color: var(--muted);
font: 700 12px/1 "JetBrains Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.status-value {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text);
font: 700 13px/1 "JetBrains Mono", monospace;
}
.dot {
width: 9px;
height: 9px;
border-radius: 99px;
background: var(--faint);
}
.dot.on { background: var(--green); box-shadow: 0 0 16px var(--green); }
.dot.warn { background: var(--yellow); box-shadow: 0 0 16px var(--yellow); }
.tab-panel {
position: sticky;
top: 14px;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border-radius: 24px;
margin-bottom: 18px;
}
.tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tab-btn {
border: 1px solid transparent;
color: var(--muted);
background: transparent;
border-radius: 16px;
padding: 12px 16px;
cursor: pointer;
font-weight: 800;
transition: 160ms ease;
}
.tab-btn:hover { color: var(--text); background: rgba(255,255,255,0.06); }
.tab-btn.active {
color: #061014;
background: linear-gradient(135deg, var(--cyan), var(--green));
box-shadow: 0 12px 28px rgba(0,229,255,0.18);
}
.filter-row {
display: flex;
align-items: center;
gap: 10px;
color: var(--muted);
font-size: 13px;
}
select {
min-width: 240px;
color: var(--text);
background: rgba(5,8,14,0.78);
border: 1px solid var(--line);
border-radius: 14px;
padding: 11px 14px;
outline: none;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 18px;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
.voice-layout {
display: grid;
grid-template-columns: 380px 1fr;
gap: 18px;
}
.content-card {
border-radius: 28px;
padding: 22px;
}
.card-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.card-title h2 {
margin: 0;
font-family: "Archivo Black", sans-serif;
font-size: 26px;
letter-spacing: -0.04em;
text-transform: uppercase;
}
.mini {
color: var(--faint);
font: 700 11px/1 "JetBrains Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.field-group { display: grid; gap: 8px; margin-bottom: 14px; }
.field-group label { color: var(--muted); font-size: 13px; font-weight: 800; }
.button-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.btn {
border: 0;
border-radius: 16px;
padding: 13px 16px;
color: #061014;
cursor: pointer;
font-weight: 900;
transition: transform 140ms ease, filter 140ms ease;
}
.btn:hover { transform: translateY(-1px); filter: brightness(1.08); }
.btn-primary { background: linear-gradient(135deg, var(--cyan), var(--blue)); color: white; }
.btn-success { background: linear-gradient(135deg, var(--green), var(--cyan)); }
.btn-danger { background: linear-gradient(135deg, var(--red), #ff9a6b); color: white; }
.voice-status { color: var(--muted); font-size: 13px; margin-top: 12px; min-height: 20px; }
.visualizer {
display: flex;
align-items: flex-end;
gap: 5px;
height: 130px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 20px;
background: rgba(0,0,0,0.22);
overflow: hidden;
}
.bar {
flex: 1;
min-width: 5px;
border-radius: 999px;
height: 3px;
background: linear-gradient(to top, var(--blue), var(--cyan), var(--green));
box-shadow: 0 0 18px rgba(0,229,255,0.16);
}
.participants {
display: grid;
gap: 10px;
}
.user-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.035);
}
.user-item.speaking { border-color: rgba(57,255,136,0.55); background: rgba(57,255,136,0.08); }
.user-item img { width: 34px; height: 34px; border-radius: 999px; }
.feed {
display: grid;
gap: 12px;
}
.event-card {
display: grid;
gap: 10px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 20px;
background: rgba(255,255,255,0.035);
}
.event-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.author {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.avatar {
width: 34px;
height: 34px;
border-radius: 999px;
background: linear-gradient(135deg, var(--blue), var(--cyan));
flex: 0 0 auto;
}
.avatar img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
.name { font-weight: 900; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.time { color: var(--faint); font: 600 11px/1 "JetBrains Mono", monospace; white-space: nowrap; }
.message-text { color: #dbe6f7; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
.sticker-strip, .attachment-strip { display: flex; gap: 10px; flex-wrap: wrap; }
.sticker-img { width: 96px; height: 96px; object-fit: contain; border-radius: 16px; background: rgba(0,0,0,0.22); border: 1px solid var(--line); padding: 8px; }
.attachment-chip { color: var(--cyan); text-decoration: none; border: 1px solid var(--line); border-radius: 14px; padding: 8px 10px; font: 700 12px/1 "JetBrains Mono", monospace; background: rgba(0,229,255,0.06); }
.embed-card { border-left: 4px solid var(--blue); border-radius: 16px; padding: 12px; background: rgba(98,117,255,0.08); display: grid; gap: 8px; }
.embed-title { font-weight: 900; color: var(--text); }
.embed-description { color: var(--muted); line-height: 1.5; white-space: pre-wrap; }
.embed-image { max-width: 360px; width: 100%; border-radius: 14px; border: 1px solid var(--line); }
.badges { display: flex; gap: 8px; flex-wrap: wrap; }
.badge {
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 9px;
color: var(--muted);
font: 700 10px/1 "JetBrains Mono", monospace;
text-transform: uppercase;
}
.badge.edit { color: var(--yellow); border-color: rgba(255,228,94,0.36); }
.badge.delete { color: var(--red); border-color: rgba(255,79,109,0.42); }
.filename { font-size: 13px; font-weight: 900; word-break: break-word; }
.link { color: var(--cyan); text-decoration: none; font-weight: 900; }
.link:hover { text-decoration: underline; }
.empty {
padding: 34px;
text-align: center;
color: var(--faint);
border: 1px dashed var(--line);
border-radius: 22px;
}
.error {
display: none;
margin-bottom: 14px;
padding: 12px 14px;
color: #ffd8df;
border: 1px solid rgba(255,79,109,0.5);
border-radius: 16px;
background: rgba(255,79,109,0.12);
}
@media (max-width: 980px) {
.hero, .voice-layout { grid-template-columns: 1fr; }
.tab-panel { align-items: stretch; flex-direction: column; }
.filter-row { align-items: stretch; flex-direction: column; }
select { width: 100%; min-width: 0; }
}

View File

@@ -1,184 +0,0 @@
<!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 shared-state dashboard with legacy working voice bridge and captured Discord messages.</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>Text Guild</span><select id="textGuildSelect"><option value="">Select guild</option></select><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="voiceGuildSelect">Voice Guild</label><select id="voiceGuildSelect"><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 class="content-card">
<div class="card-title"><h2>Media</h2><span class="mini" id="mediaStatus">Idle</span></div>
<div class="field-group"><label for="mediaSourceInput">Music URL / file path</label><input id="mediaSourceInput" type="text" placeholder="https://example.com/song.mp3"></div>
<div class="button-row"><button id="queueMediaBtn" class="btn btn-primary">Queue / Play</button><button id="skipMediaBtn" class="btn btn-success">Skip</button><button id="stopMediaBtn" class="btn btn-danger">Stop</button></div>
<div id="mediaQueueList" class="feed"><div class="empty">No media queued</div></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 style="display:grid;grid-template-columns:1fr 1fr;gap:16px;height:100%"><div class="content-card"><div class="card-title"><h2>All Messages</h2><span class="mini">all captures</span></div><div id="textList" class="feed"><div class="empty">Select channel to view text captures</div></div></div><div class="content-card"><div class="card-title"><h2>Needs Review</h2><span class="mini">warn + flagged</span></div><div id="reviewList" class="feed"><div class="empty">No warned or flagged messages</div></div></div></div></section>
</main>
<script>
const state = {
socket: null,
selectedVoiceGuild: '',
selectedVoiceChannel: '',
selectedTextGuild: '',
selectedTextChannel: '',
activeTab: 'voice',
text: [],
isStreaming: false,
isListening: false,
localStreaming: false,
localListening: false,
audioContextTransmit: null,
audioContextListen: null,
processor: null,
userTimelines: new Map(),
applyingServerState: false,
media: { playing: false, current: null, queue: [] },
};
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'), voiceGuildSelect: document.getElementById('voiceGuildSelect'), textGuildSelect: document.getElementById('textGuildSelect'), 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'), reviewList: document.getElementById('reviewList'), mediaSourceInput: document.getElementById('mediaSourceInput'), mediaStatus: document.getElementById('mediaStatus'), queueMediaBtn: document.getElementById('queueMediaBtn'), skipMediaBtn: document.getElementById('skipMediaBtn'), stopMediaBtn: document.getElementById('stopMediaBtn'), mediaQueueList: document.getElementById('mediaQueueList')
};
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 postUIState(patch) { return apiRequest('/api/ui-state', { method: 'POST', body: JSON.stringify(patch) }); }
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.voiceGuildSelect, guilds, 'Select guild'); renderOptions(el.textGuildSelect, guilds, 'Select guild'); if (state.selectedVoiceGuild) { el.voiceGuildSelect.value = state.selectedVoiceGuild; await loadVoiceChannels(state.selectedVoiceGuild); } if (state.selectedTextGuild) { el.textGuildSelect.value = state.selectedTextGuild; await loadTextChannels(state.selectedTextGuild); } }
async function loadVoiceChannels(guildId) { if (!guildId) return renderOptions(el.channelSelect, [], 'Select voice channel'); const voiceChannels = await apiRequest(`/api/guilds/${guildId}/voice-channels`); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; }
async function loadTextChannels(guildId) { if (!guildId) return renderOptions(el.channelFilter, [], 'Select channel'); const watchChannels = await apiRequest(`/api/guilds/${guildId}/channels`); renderOptions(el.channelFilter, watchChannels, 'Select channel'); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; apiRequest(`/api/guilds/${guildId}/threads`).then((threads) => { appendOptions(el.channelFilter, threads); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; }).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.voiceGuildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: channelId }); 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 === 'ui_state') return applyServerState(message.state); 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(); if (message.type === 'message_analyzed') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, message.data); renderText(); } if (message.type === 'media_state') { state.media = message.state; renderMedia(); } }
async function applyServerState(next) {
if (!next || state.applyingServerState) return;
state.applyingServerState = true;
const nextVoiceGuild = next.selectedVoiceGuild || next.selectedGuild || '';
const nextTextGuild = next.selectedTextGuild || next.selectedGuild || '';
const voiceGuildChanged = nextVoiceGuild !== state.selectedVoiceGuild;
const textGuildChanged = nextTextGuild !== state.selectedTextGuild;
const textChanged = next.selectedTextChannel !== state.selectedTextChannel;
state.selectedVoiceGuild = nextVoiceGuild;
state.selectedVoiceChannel = next.selectedVoiceChannel || '';
state.selectedTextGuild = nextTextGuild;
state.selectedTextChannel = next.selectedTextChannel || '';
state.activeTab = next.activeTab || 'voice';
state.isListening = !!next.isListening;
state.isStreaming = !!next.isStreaming;
el.voiceGuildSelect.value = state.selectedVoiceGuild;
el.textGuildSelect.value = state.selectedTextGuild;
if (voiceGuildChanged) await loadVoiceChannels(state.selectedVoiceGuild);
if (textGuildChanged) await loadTextChannels(state.selectedTextGuild);
el.channelSelect.value = state.selectedVoiceChannel;
el.channelFilter.value = state.selectedTextChannel;
applyActiveTab(state.activeTab);
if ((textChanged || textGuildChanged) && state.selectedTextChannel && state.selectedTextGuild) {
await apiRequest('/api/backlog-sync', {
method: 'POST',
body: JSON.stringify({ guildId: state.selectedTextGuild, channelId: state.selectedTextChannel }),
}).catch((error) => showError(`Backlog sync failed: ${error.message}`));
}
if (textChanged || textGuildChanged || state.activeTab === 'text') {
await fetchText().catch((error) => showError(error.message));
}
await reconcileListenState();
await reconcileStreamingState();
state.applyingServerState = false;
}
function applyActiveTab(tab) { document.querySelectorAll('.tab-btn').forEach((item) => item.classList.toggle('active', item.dataset.tab === tab)); document.querySelectorAll('.tab-content').forEach((item) => item.classList.toggle('active', item.id === tab)); el.activeTabLabel.textContent = tab === 'text' ? 'Text' : 'Voice'; }
async function reconcileListenState() { if (state.isListening && !state.localListening) { try { await startListeningLocal(); } catch (error) { showError(`Speaker error: ${error.message}`); await postUIState({ isListening: false }); } } else if (!state.isListening && state.localListening) { stopListeningLocal(); } }
async function reconcileStreamingState() { if (state.isStreaming && !state.localStreaming) { try { await startStreamingLocal(); } catch (error) { showError(`Microphone error: ${error.message}`); await postUIState({ isStreaming: false }); } } else if (!state.isStreaming && state.localStreaming) { stopStreamingLocal(); } }
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.selectedTextChannel) return renderText(); const result = await apiRequest(`/api/messages?channel=${encodeURIComponent(state.selectedTextChannel)}&type=text&limit=80`); state.text = result.data || []; renderText(); }
function renderText() { el.textList.replaceChildren(); el.reviewList.replaceChildren(); if (!state.selectedTextChannel) { appendEmpty(el.textList, 'Select channel to view text captures'); appendEmpty(el.reviewList, 'No warned or flagged messages'); return; } if (state.text.length === 0) { appendEmpty(el.textList, 'No text captures yet'); appendEmpty(el.reviewList, 'No warned or flagged messages'); return; } const reviewMessages = []; 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); appendAIAnalysis(card, msg); 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); if (msg.ai_status === 'warn' || msg.ai_status === 'flagged') reviewMessages.push(card.cloneNode(true)); } if (reviewMessages.length === 0) { appendEmpty(el.reviewList, 'No warned or flagged messages'); } else { reviewMessages.forEach((card) => el.reviewList.appendChild(card)); } }
function appendAIAnalysis(card, msg) { const status = msg.ai_status || 'pending'; const wrap = document.createElement('div'); wrap.className = 'badges'; const badge = document.createElement('span'); badge.className = `badge ${status === 'flagged' ? 'delete' : status === 'clean' ? 'edit' : ''}`; badge.textContent = `AI: ${status}`; wrap.appendChild(badge); if (msg.ai_moderation_flags) { const flags = document.createElement('span'); flags.className = 'badge delete'; try { flags.textContent = JSON.parse(msg.ai_moderation_flags).join(', '); } catch { flags.textContent = msg.ai_moderation_flags; } wrap.appendChild(flags); } card.appendChild(wrap); if (msg.ai_analysis) { const analysis = document.createElement('div'); analysis.className = 'embed-description'; analysis.textContent = msg.ai_analysis; card.appendChild(analysis); } if (msg.ai_error) { const error = document.createElement('div'); error.className = 'embed-description'; error.textContent = `AI error: ${msg.ai_error}`; card.appendChild(error); } }
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.localListening || !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 startStreamingLocal() { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); state.localStreaming = 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.localStreaming || 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)}%`; }); }; }
function stopStreamingLocal() { state.localStreaming = 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'; }); }
async function startListeningLocal() { if (!state.audioContextListen) state.audioContextListen = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE }); await state.audioContextListen.resume(); state.localListening = true; el.listenBtn.textContent = 'Stop Listening'; el.listenStatus.textContent = 'Listening Live...'; }
function stopListeningLocal() { state.audioContextListen?.suspend(); state.userTimelines.clear(); state.localListening = false; el.listenBtn.textContent = 'Join Listen Channel'; el.listenStatus.textContent = 'Speaker Off'; }
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', () => postUIState({ activeTab: button.dataset.tab }).catch((error) => showError(error.message))); });
el.voiceGuildSelect.addEventListener('change', () => postUIState({ selectedVoiceGuild: el.voiceGuildSelect.value, selectedVoiceChannel: '' }).catch((error) => showError(error.message)));
el.textGuildSelect.addEventListener('change', () => postUIState({ selectedTextGuild: el.textGuildSelect.value, selectedTextChannel: '' }).catch((error) => showError(error.message)));
el.channelSelect.addEventListener('change', () => postUIState({ selectedVoiceChannel: el.channelSelect.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', () => postUIState({ isStreaming: !state.isStreaming }).catch((error) => showError(error.message)));
el.listenBtn.addEventListener('click', () => postUIState({ isListening: !state.isListening }).catch((error) => showError(error.message)));
el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.textGuildSelect.value) url.searchParams.set('guild', el.textGuildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); });
async function fetchMediaStatus() { state.media = await apiRequest('/api/media/status'); renderMedia(); }
async function queueMedia() { const source = el.mediaSourceInput.value.trim(); if (!source) return showError('Enter a music URL or local file path'); state.media = await apiRequest('/api/media/queue', { method: 'POST', body: JSON.stringify({ source }) }); el.mediaSourceInput.value = ''; renderMedia(); }
async function skipMedia() { state.media = await apiRequest('/api/media/skip', { method: 'POST' }); renderMedia(); }
async function stopMedia() { state.media = await apiRequest('/api/media/stop', { method: 'POST' }); renderMedia(); }
function renderMedia() { el.mediaQueueList.replaceChildren(); const current = state.media.current; el.mediaStatus.textContent = current ? `Playing ${current.title}` : 'Idle'; if (current) { const item = document.createElement('div'); item.className = 'event-card'; item.textContent = `Now: ${current.title}`; el.mediaQueueList.appendChild(item); } for (const queued of state.media.queue || []) { const item = document.createElement('div'); item.className = 'event-card'; item.textContent = queued.title; el.mediaQueueList.appendChild(item); } if (!current && (!state.media.queue || state.media.queue.length === 0)) appendEmpty(el.mediaQueueList, 'No media queued'); }
el.queueMediaBtn.addEventListener('click', () => queueMedia().catch((error) => showError(error.message)));
el.skipMediaBtn.addEventListener('click', () => skipMedia().catch((error) => showError(error.message)));
el.stopMediaBtn.addEventListener('click', () => stopMedia().catch((error) => showError(error.message)));
connectWebSocket();
apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).then(fetchMediaStatus).catch((error) => showError(error.message));
setInterval(() => { if (state.activeTab === 'text') fetchText().catch(() => {}); }, 7000);
</script>
</body>
</html>

34
scripts/install-yt-dlp.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env sh
set -eu
if command -v yt-dlp >/dev/null 2>&1; then
echo "yt-dlp already installed: $(command -v yt-dlp)"
yt-dlp --version
exit 0
fi
if command -v pacman >/dev/null 2>&1; then
sudo pacman -S --needed yt-dlp
elif command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y yt-dlp
elif command -v dnf >/dev/null 2>&1; then
sudo dnf install -y yt-dlp
elif command -v brew >/dev/null 2>&1; then
brew install yt-dlp
elif command -v pipx >/dev/null 2>&1; then
pipx install yt-dlp
elif command -v python3 >/dev/null 2>&1; then
python3 -m pip install --user --upgrade yt-dlp
else
echo "Could not find pacman, apt-get, dnf, brew, pipx, or python3 to install yt-dlp." >&2
exit 1
fi
if ! command -v yt-dlp >/dev/null 2>&1; then
echo "yt-dlp installed but is not on PATH. Restart your shell or add the installer bin directory to PATH." >&2
exit 1
fi
echo "yt-dlp installed: $(command -v yt-dlp)"
yt-dlp --version

View File

@@ -76,6 +76,7 @@ const configSchema = z
POSTGRES_DB: z.string().optional(), POSTGRES_DB: z.string().optional(),
POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2), POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2),
POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10), POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10),
ADMIN_PASSWORD: z.string().default("admin123"),
}) })
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
if (!value.AI_ANALYSIS_ENABLED) { if (!value.AI_ANALYSIS_ENABLED) {

View File

@@ -19,11 +19,15 @@ let db:
*/ */
export async function initializeDatabase() { export async function initializeDatabase() {
if (db !== null) { if (db !== null) {
logger.debug("Database already initialized, skipping");
return db; return db;
} }
if (config.DATABASE_TYPE === "postgres") { // During tests prefer an isolated SQLite instance to avoid using shared
// external Postgres instances which can lead to flaky test interference.
const usePostgres =
config.DATABASE_TYPE === "postgres" && process.env.NODE_ENV !== "test";
if (usePostgres) {
let pool: Pool; let pool: Pool;
// Use DATABASE_URL if available, otherwise build from individual variables // Use DATABASE_URL if available, otherwise build from individual variables
@@ -46,12 +50,25 @@ export async function initializeDatabase() {
} }
db = drizzlePostgres(pool, { schema }); db = drizzlePostgres(pool, { schema });
// Provide a simple `run` helper for tests that expect it.
try {
(db as any).run = (sql: string) => pool.query(sql);
} catch {
// ignore
}
logger.info("PostgreSQL database initialized"); logger.info("PostgreSQL database initialized");
} else { } else {
const sqlite = new Database(".muxer-queue.db"); const sqlite = new Database(".muxer-queue.db");
sqlite.pragma("journal_mode = WAL"); sqlite.pragma("journal_mode = WAL");
db = drizzleSqlite(sqlite, { schema }); db = drizzleSqlite(sqlite, { schema });
// Expose a convenience `run` method used by tests that expect a simple API.
// `sqlite` is the underlying better-sqlite3 Database instance.
try {
(db as any).run = (sql: string) => sqlite.exec(sql);
} catch {
// ignore
}
logger.info("SQLite database initialized"); logger.info("SQLite database initialized");
} }

View File

@@ -73,6 +73,14 @@ async function initializeApp() {
process.exit(1); process.exit(1);
} }
client.on("debug", (msg) => {
if (msg.includes("[VOICE") || msg.includes("[ffmpeg") || msg.toLowerCase().includes("error") || msg.toLowerCase().includes("stream")) {
logger.info({ debugMsg: msg }, "Discord Client Debug");
} else if (config.VERBOSE) {
logger.debug({ debugMsg: msg }, "Discord Client Debug");
}
});
client.on("ready", async () => { client.on("ready", async () => {
logger.info({ user: client.user?.tag }, "Bot logged in"); logger.info({ user: client.user?.tag }, "Bot logged in");
registerMessageCapture(client); registerMessageCapture(client);

View File

@@ -3,19 +3,27 @@ import { discordPlayer } from "../player";
import { MediaQueue } from "./mediaQueue"; import { MediaQueue } from "./mediaQueue";
import { resolveMediaSource } from "./mediaResolver"; import { resolveMediaSource } from "./mediaResolver";
import type { import type {
MediaMode,
MediaState, MediaState,
MusicPlayback, MusicPlayback,
MusicPlayer, MusicPlayer,
QueueMediaOptions,
ResolvedMediaSource, ResolvedMediaSource,
ScreenShareController,
ScreenSharePlayback,
} from "./mediaTypes"; } from "./mediaTypes";
import { createMusicPlayer } from "./musicPlayer"; import { createMusicPlayer } from "./musicPlayer";
export interface MediaControllerDependencies { export interface MediaControllerDependencies {
isVoiceConnected?: () => boolean; isVoiceConnected?: () => boolean;
isBrowserStreaming?: () => boolean; isBrowserStreaming?: () => boolean;
resolveMediaSource?: (source: string) => Promise<ResolvedMediaSource>; resolveMediaSource?: (source: string, mode?: MediaMode) => Promise<ResolvedMediaSource>;
musicPlayer?: MusicPlayer; musicPlayer?: MusicPlayer;
screenController?: ScreenShareController;
onStateChange?: (state: MediaState) => void; onStateChange?: (state: MediaState) => void;
initialMusicVolume?: number;
onMusicVolumeChange?: (volume: number) => void | Promise<void>;
setMusicVolume?: (volume: number) => void;
} }
export class MediaController { export class MediaController {
@@ -24,25 +32,75 @@ export class MediaController {
private playback: MusicPlayback | null = null; private playback: MusicPlayback | null = null;
private playbackToken = 0; private playbackToken = 0;
private skipInProgress = false; private skipInProgress = false;
private screenPlayback: ScreenSharePlayback | null = null;
private activeMode: MediaMode | null = null;
private musicVolume: number;
private readonly setPlayerMusicVolume: (volume: number) => void;
constructor(private readonly dependencies: MediaControllerDependencies = {}) { constructor(private readonly dependencies: MediaControllerDependencies = {}) {
this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer(); this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer();
this.setPlayerMusicVolume =
dependencies.setMusicVolume ??
((volume) => {
discordPlayer.setMusicVolume(volume);
});
this.musicVolume = normalizeVolume(dependencies.initialMusicVolume, 1);
this.setPlayerMusicVolume(this.musicVolume);
} }
getState(): MediaState { getState(): MediaState {
const snapshot = this.queueStore.snapshot(); const snapshot = this.queueStore.snapshot();
return { return {
playing: snapshot.current?.status === "playing", playing:
this.activeMode === "screen" || snapshot.current?.status === "playing",
activeMode: this.activeMode ?? snapshot.current?.mode ?? null,
musicVolume: this.musicVolume,
...snapshot, ...snapshot,
}; };
} }
async queue(source: string): Promise<MediaState> { async setMusicVolume(volume: number): Promise<MediaState> {
this.assertCanStart(); const nextVolume = normalizeVolume(volume, this.musicVolume);
if (this.musicVolume === nextVolume) return this.emitState();
this.musicVolume = nextVolume;
this.setPlayerMusicVolume(nextVolume);
await this.dependencies.onMusicVolumeChange?.(nextVolume);
return this.emitState();
}
async queue(
source: string,
options: QueueMediaOptions = {},
): Promise<MediaState> {
const mode = options.mode ?? "music";
const resolved = await ( const resolved = await (
this.dependencies.resolveMediaSource ?? resolveMediaSource this.dependencies.resolveMediaSource ?? resolveMediaSource
)(source); )(source, mode);
this.queueStore.add(resolved);
if (mode === "screen") {
// Stop current music if any
this.playbackToken++;
this.playback?.stop();
this.playback = null;
return this.startScreen(resolved.source);
}
// mode === "music"
// If a screen share is active outside of this controller (browser-owned),
// reject to avoid stealing the shared player. If this controller started
// the screenPlayback, stop it and proceed.
if (this.screenPlayback || this.dependencies.screenController?.isActive()) {
if (this.dependencies.screenController?.isActive() && !this.screenPlayback) {
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
}
this.screenPlayback?.stop();
this.screenPlayback = null;
this.activeMode = null;
}
this.assertCanStartMusic();
this.queueStore.add(resolved, mode, options.requestedBy);
this.startNextIfIdle(); this.startNextIfIdle();
return this.emitState(); return this.emitState();
} }
@@ -73,11 +131,14 @@ export class MediaController {
this.playbackToken++; this.playbackToken++;
this.playback?.stop(); this.playback?.stop();
this.playback = null; this.playback = null;
this.screenPlayback?.stop();
this.screenPlayback = null;
this.activeMode = null;
this.queueStore.clear(); this.queueStore.clear();
return this.emitState(); return this.emitState();
} }
private assertCanStart(): void { private assertCanStartMusic(): void {
const isVoiceConnected = const isVoiceConnected =
this.dependencies.isVoiceConnected ?? (() => discordPlayer.isConnected()); this.dependencies.isVoiceConnected ?? (() => discordPlayer.isConnected());
if (!isVoiceConnected()) { if (!isVoiceConnected()) {
@@ -97,6 +158,38 @@ export class MediaController {
} }
} }
private async startScreen(source: string): Promise<MediaState> {
const screenController = this.dependencies.screenController;
if (!screenController) {
throw new AppError(
"Screen sharing is unavailable",
"SCREEN_UNAVAILABLE",
500,
);
}
this.activeMode = "screen";
try {
this.screenPlayback = await screenController.start(source);
} catch (error) {
this.activeMode = null;
throw error;
}
this.screenPlayback.done.then(
() => this.finishScreen(),
() => this.finishScreen(),
);
return this.emitState();
}
private finishScreen(): void {
if (!this.screenPlayback || this.activeMode !== "screen") return;
this.screenPlayback = null;
this.activeMode = null;
this.emitState();
}
private startNextIfIdle(): void { private startNextIfIdle(): void {
if (this.playback) return; if (this.playback) return;
const item = this.queueStore.startNext(); const item = this.queueStore.startNext();
@@ -137,3 +230,8 @@ export class MediaController {
return state; return state;
} }
} }
function normalizeVolume(value: number | undefined, fallback: number): number {
if (!Number.isFinite(value)) return fallback;
return Math.max(0, Math.min(1, value as number));
}

View File

@@ -1,4 +1,5 @@
import type { import type {
MediaMode,
MediaQueueItem, MediaQueueItem,
MediaState, MediaState,
ResolvedMediaSource, ResolvedMediaSource,
@@ -13,10 +14,14 @@ export class MediaQueue {
private readonly now = () => Date.now(), private readonly now = () => Date.now(),
) {} ) {}
add(source: ResolvedMediaSource, requestedBy = "dashboard"): MediaQueueItem { add(
source: ResolvedMediaSource,
mode: MediaQueueItem["mode"] = "music",
requestedBy = "dashboard",
): MediaQueueItem {
const item: MediaQueueItem = { const item: MediaQueueItem = {
id: this.createId(), id: this.createId(),
mode: "music", mode,
requestedBy, requestedBy,
addedAt: this.now(), addedAt: this.now(),
status: "queued", status: "queued",

View File

@@ -1,43 +1,113 @@
import { existsSync, statSync } from "node:fs"; import { existsSync, statSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { AppError } from "../errors"; import { AppError } from "../errors";
import type { ResolvedMediaSource } from "./mediaTypes"; import type { ResolvedMediaSource, MediaMode } from "./mediaTypes";
import { createPlayDlResolver } from "./playDlResolver";
import { createYtDlp, type YtDlpClient } from "./ytdlp";
export async function resolveMediaSource( type PlayDlResolver = ReturnType<typeof createPlayDlResolver>;
input: string,
): Promise<ResolvedMediaSource> { export interface MediaResolverDependencies {
const source = input.trim(); ytdlp?: YtDlpClient;
if (!source) { playDlResolver?: PlayDlResolver;
throw new AppError("Media source is required", "MISSING_MEDIA_SOURCE", 400); }
export function createMediaResolver(
dependencies: MediaResolverDependencies = {},
) {
const ytdlp = dependencies.ytdlp ?? createYtDlp();
const playDlResolver = dependencies.playDlResolver ?? createPlayDlResolver();
return async function resolve(
input: string,
mode: MediaMode = "music"
): Promise<ResolvedMediaSource> {
const source = input.trim();
if (!source) {
throw new AppError(
"Media source is required",
"MISSING_MEDIA_SOURCE",
400,
);
}
const url = parseUrl(source);
if (url && isYouTubeUrl(url)) {
const metadata = await ytdlp.getMetadata(source);
const directUrl = mode === "screen"
? await ytdlp.getDirectVideoUrl(source)
: await ytdlp.getDirectAudioUrl(source);
return { source: directUrl, title: metadata.title, kind: "youtube" };
}
if (url && isSpotifyTrackUrl(url)) {
const result = await playDlResolver.resolveSpotifyTrack(source);
const directUrl = mode === "screen"
? await ytdlp.getDirectVideoUrl(result.url)
: await ytdlp.getDirectAudioUrl(result.url);
return { source: directUrl, title: result.title, kind: "spotify" };
}
const urlSource = resolveUrlSource(source);
if (urlSource) return urlSource;
const localPath = path.resolve(source);
if (existsSync(localPath) && statSync(localPath).isFile()) {
return {
source: localPath,
title: path.basename(localPath),
kind: "local",
};
}
if (!url && !looksLikeUrl(source)) {
const result = await playDlResolver.searchYouTube(source);
const directUrl = mode === "screen"
? await ytdlp.getDirectVideoUrl(result.url)
: await ytdlp.getDirectAudioUrl(result.url);
return { source: directUrl, title: result.title, kind: "search" };
}
throw new AppError(
"Media source must be an HTTP(S) URL, YouTube URL, Spotify track URL, search query, or existing local file",
"UNSUPPORTED_MEDIA_SOURCE",
400,
);
};
}
export const resolveMediaSource = createMediaResolver();
function parseUrl(source: string): URL | null {
try {
return new URL(source);
} catch {
return null;
} }
}
const urlSource = resolveUrlSource(source); function looksLikeUrl(source: string): boolean {
if (urlSource) return urlSource; return /^[a-z][a-z\d+.-]*:/i.test(source);
}
const localPath = path.resolve(source); function isYouTubeUrl(url: URL): boolean {
if (existsSync(localPath) && statSync(localPath).isFile()) { return [
return { "youtube.com",
source: localPath, "www.youtube.com",
title: path.basename(localPath), "m.youtube.com",
kind: "local", "youtu.be",
}; ].includes(url.hostname);
} }
throw new AppError( function isSpotifyTrackUrl(url: URL): boolean {
"Media source must be an HTTP(S) URL or existing local file", return (
"UNSUPPORTED_MEDIA_SOURCE", url.hostname === "open.spotify.com" && url.pathname.startsWith("/track/")
400,
); );
} }
function resolveUrlSource(source: string): ResolvedMediaSource | null { function resolveUrlSource(source: string): ResolvedMediaSource | null {
let url: URL; const url = parseUrl(source);
try { if (!url) return null;
url = new URL(source);
} catch {
return null;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return null; if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return { return {

View File

@@ -1,7 +1,13 @@
import type { Readable } from "node:stream"; import type { Readable } from "node:stream";
import type { StreamType } from "@discordjs/voice";
export type MediaMode = "music" | "screen"; export type MediaMode = "music" | "screen";
export type MediaSourceKind = "url" | "local"; export type MediaSourceKind =
| "url"
| "local"
| "youtube"
| "spotify"
| "search";
export type MediaQueueItemStatus = "queued" | "playing" | "failed"; export type MediaQueueItemStatus = "queued" | "playing" | "failed";
export interface ResolvedMediaSource { export interface ResolvedMediaSource {
@@ -20,10 +26,17 @@ export interface MediaQueueItem extends ResolvedMediaSource {
export interface MediaState { export interface MediaState {
playing: boolean; playing: boolean;
activeMode: MediaMode | null;
musicVolume: number;
current: MediaQueueItem | null; current: MediaQueueItem | null;
queue: MediaQueueItem[]; queue: MediaQueueItem[];
} }
export interface QueueMediaOptions {
mode?: MediaMode;
requestedBy?: string;
}
export interface MusicPlayback { export interface MusicPlayback {
done: Promise<void>; done: Promise<void>;
stop(): void; stop(): void;
@@ -33,8 +46,35 @@ export interface MusicPlayer {
play(source: ResolvedMediaSource): MusicPlayback; play(source: ResolvedMediaSource): MusicPlayback;
} }
export interface DiscordAudioPlayer { export interface ScreenSharePlayback {
isConnected(): boolean; done: Promise<void>;
playStream(stream: Readable): void;
stop(): void; stop(): void;
} }
export interface ScreenShareController {
isActive(): boolean;
start(source: string): Promise<ScreenSharePlayback>;
}
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen";
export interface DiscordPlayOptions {
inputType?: StreamType;
inlineVolume?: boolean;
volume?: number;
}
export interface DiscordAudioPlayer {
getOwner(): DiscordPlayerOwner;
isConnected(): boolean;
playStream(
stream: Readable,
owner: DiscordPlayerOwner,
options?: DiscordPlayOptions,
): void;
pause(owner?: DiscordPlayerOwner): void;
unpause(owner?: DiscordPlayerOwner): boolean;
stop(owner?: DiscordPlayerOwner): void;
getMusicVolume(): number;
setMusicVolume(volume: number): void;
}

View File

@@ -1,5 +1,6 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { spawn as nodeSpawn } from "node:child_process"; import { spawn as nodeSpawn } from "node:child_process";
import { StreamType } from "@discordjs/voice";
import { discordPlayer } from "../player"; import { discordPlayer } from "../player";
import type { import type {
DiscordAudioPlayer, DiscordAudioPlayer,
@@ -30,13 +31,30 @@ export function createMusicPlayer(
}) as unknown as ChildProcessWithoutNullStreams; }) as unknown as ChildProcessWithoutNullStreams;
proc.stderr.resume(); proc.stderr.resume();
audioPlayer.playStream(proc.stdout); audioPlayer.playStream(proc.stdout, "music", {
inputType: StreamType.Raw,
inlineVolume: true,
});
let stopped = false; let stopped = false;
let released = false;
const release = () => {
if (released) return;
released = true;
audioPlayer.stop("music");
};
const done = new Promise<void>((resolve, reject) => { const done = new Promise<void>((resolve, reject) => {
proc.on("error", reject); proc.on("error", (error) => {
proc.stdout.on("error", reject); release();
reject(error);
});
proc.stdout.on("error", (error) => {
release();
reject(error);
});
proc.on("close", (code) => { proc.on("close", (code) => {
release();
if (code === 0 || stopped) { if (code === 0 || stopped) {
resolve(); resolve();
return; return;
@@ -51,7 +69,7 @@ export function createMusicPlayer(
if (stopped) return; if (stopped) return;
stopped = true; stopped = true;
proc.kill("SIGTERM"); proc.kill("SIGTERM");
audioPlayer.stop(); release();
}, },
}; };
}, },
@@ -67,13 +85,13 @@ export function buildFfmpegArgs(source: string): string[] {
source, source,
"-vn", "-vn",
"-acodec", "-acodec",
"libopus", "pcm_s16le",
"-ar", "-ar",
"48000", "48000",
"-ac", "-ac",
"2", "2",
"-f", "-f",
"ogg", "s16le",
"pipe:1", "pipe:1",
]; ];
} }

View File

@@ -0,0 +1,60 @@
import play from "play-dl";
export interface PlayDlResult {
title: string;
url: string;
}
interface PlayDlSearchResult {
title?: string;
url?: string;
}
interface SpotifyTrackLike {
type?: string;
name?: string;
artists?: Array<{ name?: string }>;
}
type SearchFunction = (
query: string,
options: { limit: number },
) => Promise<PlayDlSearchResult[]>;
type SpotifyFunction = (url: string) => Promise<SpotifyTrackLike>;
export interface PlayDlDependencies {
search?: SearchFunction;
spotify?: SpotifyFunction;
}
export function createPlayDlResolver(dependencies: PlayDlDependencies = {}) {
const search: SearchFunction = dependencies.search ?? play.search;
const spotify: SpotifyFunction =
dependencies.spotify ?? (play.spotify as SpotifyFunction);
return {
async searchYouTube(query: string): Promise<PlayDlResult> {
const results = await search(query, { limit: 1 });
const first = results[0];
if (!first?.url) throw new Error(`No YouTube result found for ${query}`);
return {
title: first.title || query,
url: first.url,
};
},
async resolveSpotifyTrack(url: string): Promise<PlayDlResult> {
const track = await spotify(url);
if (track.type !== "track") {
throw new Error("Only Spotify track URLs are supported");
}
const artists = (track.artists || [])
.map((artist) => artist.name)
.filter(Boolean)
.join(" ");
const query = `${artists} ${track.name || ""} audio`.trim();
return this.searchYouTube(query);
},
};
}

View File

@@ -0,0 +1,159 @@
import {
Streamer,
playPreparedStream,
} from "../streaming";
import { AppError } from "../errors";
import { createChildLogger } from "../logger";
import { discordPlayer } from "../player";
const logger = createChildLogger("screen-share");
import type { DiscordPlayerOwner, ScreenSharePlayback } from "./mediaTypes";
import { createYtDlp } from "./ytdlp";
export interface ScreenShareVoiceStatus {
connected: boolean;
activeGuildId: string | null;
activeChannelId: string | null;
}
export interface ScreenShareControllerDependencies {
getVoiceStatus: () => ScreenShareVoiceStatus;
getPlayerOwner?: () => DiscordPlayerOwner;
getDirectVideoUrl?: (source: string) => Promise<string>;
streamer: Streamer;
useTranscoder?: boolean;
onBeforeStreamStart?: (guildId: string, channelId: string) => Promise<void> | void;
onAfterStreamEnd?: (guildId: string, channelId: string) => Promise<void> | void;
onStreamStart?: () => void;
onStreamEnd?: () => void;
}
export function createScreenShareController(
dependencies: ScreenShareControllerDependencies,
) {
let active: ScreenSharePlayback | null = null;
const ytdlp = createYtDlp();
const getPlayerOwner =
dependencies.getPlayerOwner ?? (() => discordPlayer.getOwner());
const getDirectVideoUrl =
dependencies.getDirectVideoUrl ??
((source) => ytdlp.getDirectVideoUrl(source));
return {
isActive(): boolean {
return active !== null;
},
async start(source: string): Promise<ScreenSharePlayback> {
const status = dependencies.getVoiceStatus();
let voiceReleased = false;
let voiceRestored = false;
const restoreVoice = async () => {
if (voiceRestored || !voiceReleased || !guildId || !channelId) return;
voiceRestored = true;
await dependencies.onAfterStreamEnd?.(guildId, channelId);
};
if (active) {
active.stop();
}
// Ensure bot is in the voice channel and owns the screen-share stream
if (
!status.connected ||
!status.activeGuildId ||
!status.activeChannelId
) {
throw new AppError(
"Connect to a voice channel before sharing screen",
"VOICE_NOT_CONNECTED",
409,
);
}
const guildId = status.activeGuildId;
const channelId = status.activeChannelId;
// If another media owner (e.g. music) holds the shared player, reject
const owner = getPlayerOwner();
if (owner === "music") {
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
}
try {
const directUrl = await getDirectVideoUrl(source);
logger.info(
{
guildId,
channelId,
},
"Creating screen share session",
);
await dependencies.onBeforeStreamStart?.(guildId, channelId);
voiceReleased = true;
const session = await dependencies.streamer.createSession(
guildId,
channelId,
);
dependencies.onStreamStart?.();
let stopped = false;
const playFn = dependencies.useTranscoder
? (await import("../streaming")).playTranscodedPreparedStream
: (await import("../streaming")).playPreparedStream;
const done = playFn(directUrl, session, {
fps: 30,
bitrate: 2500,
includeAudio: true,
presetH26x: "superfast",
}).finally(() => {
active = null;
dependencies.onStreamEnd?.();
return restoreVoice();
});
done.catch(() => undefined);
logger.info(
{
guildId,
channelId,
},
"Screen share session started",
);
active = {
done,
stop() {
if (stopped) return;
stopped = true;
session.stop();
active = null;
void restoreVoice();
},
};
return active;
} catch (error) {
active = null;
if (voiceReleased) {
await restoreVoice();
}
logger.error(
{
error,
guildId,
channelId,
},
"Screen share startup failed",
);
throw new AppError(
error instanceof Error ? error.message : "Screen stream failed",
"SCREEN_STREAM_FAILED",
500,
);
}
},
};
}

95
src/media/ytdlp.ts Normal file
View File

@@ -0,0 +1,95 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { spawn as nodeSpawn } from "node:child_process";
export interface YtDlpMetadata {
title: string;
webpageUrl: string;
}
export interface YtDlpClient {
getMetadata(url: string): Promise<YtDlpMetadata>;
getDirectAudioUrl(url: string): Promise<string>;
getDirectVideoUrl(url: string): Promise<string>;
}
export interface YtDlpDependencies {
spawn?: typeof nodeSpawn;
}
export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient {
const spawn = dependencies.spawn ?? nodeSpawn;
return {
async getMetadata(url: string): Promise<YtDlpMetadata> {
const data = await runYtDlp(spawn, [
url,
"--dump-single-json",
"--no-playlist",
"--no-warnings",
"--quiet",
]);
const parsed = JSON.parse(data) as {
title?: string;
webpage_url?: string;
};
return {
title: parsed.title || url,
webpageUrl: parsed.webpage_url || url,
};
},
async getDirectAudioUrl(url: string): Promise<string> {
const value = await runYtDlp(spawn, [
url,
"--get-url",
"--format",
"bestaudio[protocol^=http]/bestaudio/best",
"--no-playlist",
"--no-warnings",
"--quiet",
]);
return value.trim().split("\n")[0] || url;
},
async getDirectVideoUrl(url: string): Promise<string> {
const value = await runYtDlp(spawn, [
url,
"--get-url",
"--format",
"best[protocol^=http]/best",
"--no-playlist",
"--no-warnings",
"--quiet",
]);
return value.trim();
},
};
}
async function runYtDlp(
spawn: typeof nodeSpawn,
args: string[],
): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn("yt-dlp", args, {
stdio: ["ignore", "pipe", "pipe"],
}) as unknown as ChildProcessWithoutNullStreams;
let stdout = "";
let stderr = "";
proc.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
proc.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
proc.on("error", reject);
proc.on("close", (code) => {
if (code === 0) {
resolve(stdout);
return;
}
reject(new Error(`yt-dlp failed with code ${code}: ${stderr.trim()}`));
});
});
}

View File

@@ -53,6 +53,17 @@ export const wsMessagesCounter = new Counter({
labelNames: ["message_type"], labelNames: ["message_type"],
}); });
// Transcoder metrics
export const transcoderRestartsCounter = new Counter({
name: "transcoder_restarts_total",
help: "Total number of transcoder restarts",
});
export const transcoderRunningGauge = new Gauge({
name: "transcoder_running",
help: "Whether a transcoder process is currently running (1/0)",
});
// HTTP metrics // HTTP metrics
export const httpRequestDurationHistogram = new Histogram({ export const httpRequestDurationHistogram = new Histogram({
name: "http_request_duration_seconds", name: "http_request_duration_seconds",

View File

@@ -1,3 +1,7 @@
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
// Mock node-crc to provide pure JS implementation and bypass native build issues // Mock node-crc to provide pure JS implementation and bypass native build issues
const CRC_TABLE = new Uint32Array(256); const CRC_TABLE = new Uint32Array(256);
for (let i = 0; i < 256; i++) { for (let i = 0; i < 256; i++) {
@@ -39,4 +43,6 @@ Module.prototype.require = function (id: string) {
return originalRequire.apply(this, arguments); return originalRequire.apply(this, arguments);
}; };
console.log("[mock] node-crc has been mocked globally."); console.log("[mock] node-crc has been mocked globally for ESM.");
export {};

View File

@@ -0,0 +1,112 @@
import { parentPort } from "node:worker_threads";
import { initializeDatabase } from "../database/drizzle";
import { buildConversationPromptMessages } from "./conversationContext";
import { runModerationAnalysis } from "./llmModerationClient";
import {
getConversationContextBefore,
updateMessageAIAnalysis,
} from "./messageStore";
import type { MessageRecord } from "./types";
const MAX_CONTEXT_TOKENS = 8000;
let dbInitialized = false;
interface AnalysisWorkerRequest {
conversationKey: string;
messages: MessageRecord[];
}
type AnalysisWorkerResponse =
| {
ok: true;
conversationKey: string;
rows: MessageRecord[];
}
| {
ok: false;
conversationKey: string;
rows: MessageRecord[];
error: string;
};
async function processAnalysisRequest({
conversationKey,
messages,
}: AnalysisWorkerRequest): Promise<AnalysisWorkerResponse> {
try {
try {
if (!dbInitialized) {
await initializeDatabase();
dbInitialized = true;
}
} catch (dbError) {
const msg = dbError instanceof Error ? dbError.message : String(dbError);
return {
ok: false,
conversationKey,
rows: [],
error: `Database init failed: ${msg}`,
};
}
const firstMessage = messages[0];
if (!firstMessage) return { ok: true, conversationKey, rows: [] };
const contextBefore = await getConversationContextBefore({
channelId: firstMessage.channel_id,
threadId: firstMessage.thread_id,
beforeCreatedAt: firstMessage.created_at,
limit: 20,
});
const promptMessages = buildConversationPromptMessages({
contextBefore,
targets: messages,
maxTokens: MAX_CONTEXT_TOKENS,
});
const result = await runModerationAnalysis({
targets: messages,
contextText: promptMessages.join("\n"),
});
const rows: MessageRecord[] = [];
for (const analysisResult of result.results) {
const row = await updateMessageAIAnalysis(analysisResult.messageId, {
status: analysisResult.status,
flags: JSON.stringify(analysisResult.flags),
score: analysisResult.score,
raw: JSON.stringify(result.raw),
analysis: analysisResult.analysis,
analyzedAt: Date.now(),
error: null,
});
if (row) rows.push(row);
}
return { ok: true, conversationKey, rows };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const rows: MessageRecord[] = [];
for (const msg of messages) {
const row = await updateMessageAIAnalysis(msg.id, {
status: "error",
flags: null,
score: null,
raw: null,
analysis: null,
analyzedAt: Date.now(),
error: errorMessage,
});
if (row) rows.push(row);
}
return { ok: false, conversationKey, rows, error: errorMessage };
}
}
parentPort?.on("message", async (request: AnalysisWorkerRequest) => {
parentPort?.postMessage(await processAnalysisRequest(request));
});

View File

@@ -1,9 +1,7 @@
import { Worker } from "node:worker_threads";
import { config } from "../config"; import { config } from "../config";
import { createChildLogger } from "../logger"; import { createChildLogger } from "../logger";
import { buildConversationPromptMessages } from "./conversationContext";
import { runModerationAnalysis } from "./llmModerationClient";
import { import {
getConversationContextBefore,
getMessageById, getMessageById,
getPendingConversationKeys, getPendingConversationKeys,
getPendingMessagesByConversation, getPendingMessagesByConversation,
@@ -38,9 +36,15 @@ const MAX_ACTIVE_REQUESTS = 1;
const DEBOUNCE_MS = 1500; const DEBOUNCE_MS = 1500;
const RECOVERY_INTERVAL_MS = 15000; const RECOVERY_INTERVAL_MS = 15000;
const ERROR_COOLDOWN_MS = 30000; const ERROR_COOLDOWN_MS = 30000;
const MAX_CONTEXT_TOKENS = 8000;
const MAX_BATCH_SIZE = 25; const MAX_BATCH_SIZE = 25;
interface AnalysisWorkerResponse {
ok: boolean;
conversationKey: string;
rows: MessageRecord[];
error?: string;
}
/** /**
* Gets the conversation key for a message (thread_id or channel_id) * Gets the conversation key for a message (thread_id or channel_id)
*/ */
@@ -86,68 +90,37 @@ async function processBatch(
activeRequests++; activeRequests++;
conversationProcessing.add(conversationKey); conversationProcessing.add(conversationKey);
try { try {
// Get context before the first message const result = await runAnalysisInWorker(conversationKey, messages);
const firstMessage = messages[0];
const contextBefore = await getConversationContextBefore({
channelId: firstMessage.channel_id,
threadId: firstMessage.thread_id,
beforeCreatedAt: firstMessage.created_at,
limit: 20,
});
// Build prompt with context for (const row of result.rows) {
const promptMessages = buildConversationPromptMessages({
contextBefore,
targets: messages,
maxTokens: MAX_CONTEXT_TOKENS,
});
const contextText = promptMessages.join("\n");
// Run moderation analysis
const result = await runModerationAnalysis({
targets: messages,
contextText,
});
// Store results
const analyzedRows: MessageRecord[] = [];
for (const analysisResult of result.results) {
const row = await updateMessageAIAnalysis(analysisResult.messageId, {
status: analysisResult.status,
flags: JSON.stringify(analysisResult.flags),
score: analysisResult.score,
raw: JSON.stringify(result.raw),
analysis: analysisResult.analysis,
analyzedAt: Date.now(),
error: null,
});
if (row) {
analyzedRows.push(row);
}
}
// Broadcast analyzed messages
for (const row of analyzedRows) {
getModerationBroadcaster()?.messageAnalyzed(row); getModerationBroadcaster()?.messageAnalyzed(row);
} }
// Clear error cooldown on success if (!result.ok) {
conversationErrorCooldown.delete(conversationKey); lastError = result.error ?? "Analysis worker failed";
conversationErrorCooldown.set(
conversationKey,
Date.now() + ERROR_COOLDOWN_MS,
);
logger.error(
{ conversationKey, error: lastError },
"Batch analysis failed",
);
return;
}
logger.info( conversationErrorCooldown.delete(conversationKey);
{ conversationKey, count: messages.length },
"Batch analysis complete",
);
} catch (error) { } catch (error) {
lastError = error instanceof Error ? error.message : String(error); lastError = error instanceof Error ? error.message : String(error);
conversationErrorCooldown.set(
conversationKey,
Date.now() + ERROR_COOLDOWN_MS,
);
logger.error( logger.error(
{ conversationKey, error: lastError }, { conversationKey, error: lastError },
"Batch analysis failed", "Analysis worker failed",
); );
// Mark all messages in batch as error
for (const msg of messages) { for (const msg of messages) {
const row = await updateMessageAIAnalysis(msg.id, { const row = await updateMessageAIAnalysis(msg.id, {
status: "error", status: "error",
@@ -158,42 +131,51 @@ async function processBatch(
analyzedAt: Date.now(), analyzedAt: Date.now(),
error: lastError, error: lastError,
}); });
if (row) { if (row) getModerationBroadcaster()?.messageAnalyzed(row);
getModerationBroadcaster()?.messageAnalyzed(row);
}
} }
// Set error cooldown for this conversation
conversationErrorCooldown.set(
conversationKey,
Date.now() + ERROR_COOLDOWN_MS,
);
} finally { } finally {
activeRequests--; activeRequests--;
conversationProcessing.delete(conversationKey); conversationProcessing.delete(conversationKey);
} }
} }
async function runAnalysisInWorker(
conversationKey: string,
messages: MessageRecord[],
): Promise<AnalysisWorkerResponse> {
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL("./aiAnalysisWorker.ts", import.meta.url),
);
worker.once("message", (response: AnalysisWorkerResponse) => {
worker.terminate().catch((error) => {
logger.warn({ error }, "Failed to terminate analysis worker");
});
resolve(response);
});
worker.once("error", reject);
worker.once("exit", (code) => {
if (code !== 0) {
reject(new Error(`Analysis worker exited with code ${code}`));
}
});
worker.postMessage({ conversationKey, messages });
});
}
/** /**
* Debounced analysis trigger for a conversation * Debounced analysis trigger for a conversation
*/ */
function scheduleConversationAnalysis(conversationKey: string): void { function scheduleConversationAnalysis(conversationKey: string): void {
// Skip if already processing // Skip if already processing
if (conversationProcessing.has(conversationKey)) { if (conversationProcessing.has(conversationKey)) {
logger.debug(
{ conversationKey },
"Conversation already processing, skipping schedule",
);
return; return;
} }
// Skip if in error cooldown // Skip if in error cooldown
const cooldownUntil = conversationErrorCooldown.get(conversationKey); const cooldownUntil = conversationErrorCooldown.get(conversationKey);
if (cooldownUntil && Date.now() < cooldownUntil) { if (cooldownUntil && Date.now() < cooldownUntil) {
logger.debug(
{ conversationKey, cooldownMs: cooldownUntil - Date.now() },
"Conversation in error cooldown, skipping schedule",
);
return; return;
} }
@@ -209,10 +191,6 @@ function scheduleConversationAnalysis(conversationKey: string): void {
// If activeRequests >= MAX_ACTIVE_REQUESTS, requeue instead of waiting // If activeRequests >= MAX_ACTIVE_REQUESTS, requeue instead of waiting
if (activeRequests >= MAX_ACTIVE_REQUESTS) { if (activeRequests >= MAX_ACTIVE_REQUESTS) {
logger.debug(
{ conversationKey, activeRequests },
"Max active requests reached, requeuing conversation",
);
scheduleConversationAnalysis(conversationKey); scheduleConversationAnalysis(conversationKey);
return; return;
} }
@@ -237,8 +215,6 @@ function scheduleConversationAnalysis(conversationKey: string): void {
export async function queueMessageAnalysis(messageId: string): Promise<void> { export async function queueMessageAnalysis(messageId: string): Promise<void> {
if (!config.AI_ANALYSIS_ENABLED) return; if (!config.AI_ANALYSIS_ENABLED) return;
logger.debug({ messageId }, "Queueing message for analysis");
try { try {
// Look up the message to get its conversation key // Look up the message to get its conversation key
const message = await getMessageById(messageId); const message = await getMessageById(messageId);
@@ -267,8 +243,6 @@ export async function queueMessageAnalysis(messageId: string): Promise<void> {
export function queueConversationAnalysis(conversationKey: string): void { export function queueConversationAnalysis(conversationKey: string): void {
if (!config.AI_ANALYSIS_ENABLED) return; if (!config.AI_ANALYSIS_ENABLED) return;
logger.debug({ conversationKey }, "Queueing conversation for analysis");
// Schedule debounced analysis // Schedule debounced analysis
scheduleConversationAnalysis(conversationKey); scheduleConversationAnalysis(conversationKey);
} }
@@ -288,12 +262,7 @@ export function getAnalysisQueueStatus(): AnalysisQueueStatus {
* Starts the pending AI analysis recovery worker * Starts the pending AI analysis recovery worker
*/ */
export function startPendingAIAnalysisWorker(): void { export function startPendingAIAnalysisWorker(): void {
if (!config.AI_ANALYSIS_ENABLED) { if (!config.AI_ANALYSIS_ENABLED) return;
logger.info("AI analysis disabled");
return;
}
logger.info("AI analysis worker started");
setInterval(async () => { setInterval(async () => {
try { try {
@@ -317,10 +286,6 @@ export function startPendingAIAnalysisWorker(): void {
continue; continue;
} }
logger.debug(
{ conversationKey: key },
"Recovering pending conversation",
);
scheduleConversationAnalysis(key); scheduleConversationAnalysis(key);
} }
} catch (error) { } catch (error) {

View File

@@ -82,12 +82,7 @@ export async function uploadAttachmentToPicser(
}, },
); );
const parsed = parseUploadResponse(response); return parseUploadResponse(response);
logger.info(
{ filename, url: parsed.url },
"Attachment uploaded successfully",
);
return parsed;
} catch (error) { } catch (error) {
logger.error( logger.error(
{ {
@@ -127,8 +122,6 @@ export async function processAttachmentUpload(
filename: string, filename: string,
): Promise<void> { ): Promise<void> {
try { try {
logger.info({ attachmentId, filename }, "Starting attachment upload");
const buffer = await downloadDiscordAttachment(discordUrl); const buffer = await downloadDiscordAttachment(discordUrl);
const sizeMb = buffer.length / (1024 * 1024); const sizeMb = buffer.length / (1024 * 1024);
@@ -141,10 +134,6 @@ export async function processAttachmentUpload(
const result = await uploadAttachmentToPicser(buffer, filename); const result = await uploadAttachmentToPicser(buffer, filename);
await updateAttachmentAsUploaded(attachmentId, result.url, Date.now()); await updateAttachmentAsUploaded(attachmentId, result.url, Date.now());
logger.info(
{ attachmentId, uploadedUrl: result.url },
"Attachment upload completed",
);
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
await updateAttachmentAsFailedUpload(attachmentId, errorMsg); await updateAttachmentAsFailedUpload(attachmentId, errorMsg);

View File

@@ -73,11 +73,6 @@ export async function syncBacklogMessages(client: Client): Promise<void> {
await syncSelectedChannelBacklog(client, guild.id, config.TEXT_CHANNEL_ID); await syncSelectedChannelBacklog(client, guild.id, config.TEXT_CHANNEL_ID);
return; return;
} }
logger.info(
{ guildId: guild.id },
"Backlog sync ready (will sync on-demand per selected channel)",
);
} }
export async function syncSelectedChannelBacklog( export async function syncSelectedChannelBacklog(
@@ -102,17 +97,8 @@ export async function syncSelectedChannelBacklog(
} }
const cutoffTime = Date.now() - config.BACKLOG_SYNC_HOURS * 60 * 60 * 1000; const cutoffTime = Date.now() - config.BACKLOG_SYNC_HOURS * 60 * 60 * 1000;
logger.info(
{ guildId, channelId, hours: config.BACKLOG_SYNC_HOURS },
"Starting backlog sync for selected channel",
);
try { try {
const count = await syncChannelMessages(channel, cutoffTime); const count = await syncChannelMessages(channel, cutoffTime);
logger.info(
{ channelId, count },
"Backlog sync completed for selected channel",
);
return count; return count;
} catch (error) { } catch (error) {
logger.warn( logger.warn(

View File

@@ -26,35 +26,43 @@ export function parseModerationResponse(
content: string, content: string,
targetIds: string[], targetIds: string[],
): AnalysisResult[] { ): AnalysisResult[] {
// Find first opening brace // Find first opening brace and last closing brace
const startIdx = content.indexOf("{"); const startIdx = content.indexOf("{");
if (startIdx === -1) { const endIdx = content.lastIndexOf("}");
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
throw new Error("No JSON object found in response"); throw new Error("No JSON object found in response");
} }
// Scan from start and try parsing at each closing brace // Attempt to parse the largest possible JSON object
let parsed: unknown; let parsed: unknown;
let lastError: Error | null = null; const candidate = content.substring(startIdx, endIdx + 1);
for (let i = startIdx + 1; i < content.length; i++) { try {
if (content[i] === "}") { parsed = JSON.parse(candidate);
const candidate = content.substring(startIdx, i + 1); } catch (error) {
try { // If full substring fails, try scanning backwards from the last }
parsed = JSON.parse(candidate); let lastError: Error =
// Successfully parsed, break out error instanceof Error ? error : new Error(String(error));
break;
} catch (error) { for (let i = endIdx - 1; i > startIdx; i--) {
// Store error and continue scanning if (content[i] === "}") {
lastError = error instanceof Error ? error : new Error(String(error)); try {
continue; parsed = JSON.parse(content.substring(startIdx, i + 1));
break;
} catch (innerError) {
lastError =
innerError instanceof Error
? innerError
: new Error(String(innerError));
continue;
}
} }
} }
}
if (!parsed) { if (!parsed) {
throw new Error( throw new Error(`Failed to parse JSON: ${lastError.message}`);
`Failed to parse JSON: ${lastError?.message || "No valid JSON object found"}`, }
);
} }
// Validate structure // Validate structure
@@ -72,7 +80,7 @@ export function parseModerationResponse(
const targetIdSet = new Set(targetIds); const targetIdSet = new Set(targetIds);
// Parse and validate each result // Parse and validate each result
const results: AnalysisResult[] = response.results.map((result) => { const results: (AnalysisResult | null)[] = response.results.map((result) => {
const { message_id, status, flags, score, analysis } = result; const { message_id, status, flags, score, analysis } = result;
// Validate message_id exists and is in target list // Validate message_id exists and is in target list
@@ -80,15 +88,36 @@ export function parseModerationResponse(
throw new Error("Result missing 'message_id'"); throw new Error("Result missing 'message_id'");
} }
if (!targetIdSet.has(message_id)) { let finalId = String(message_id);
throw new Error(`Unknown message_id: ${message_id}`);
// Precision loss fix: If the ID from LLM is not found,
// try to find the closest match in targets if it looks rounded (ends in 000)
if (!targetIdSet.has(finalId)) {
if (finalId.endsWith("00") || finalId.includes("e+")) {
const roundedPrefix = finalId.substring(0, 10);
const match = targetIds.find((id) => id.startsWith(roundedPrefix));
if (match) {
log.warn(
{ roundedId: finalId, matchedId: match },
"Fixed precision loss in message ID",
);
finalId = match;
}
}
} }
if (foundIds.has(message_id)) { if (!targetIdSet.has(finalId)) {
throw new Error(`Duplicate message_id in results: ${message_id}`); throw new Error(
`Unknown message_id: ${finalId} (original: ${message_id})`,
);
} }
foundIds.add(message_id); if (foundIds.has(finalId)) {
log.warn({ duplicateId: finalId }, "Duplicate message_id in response");
throw new Error(`Duplicate message_id: ${finalId}`);
}
foundIds.add(finalId);
// Validate status // Validate status
const validStatuses = ["clean", "warn", "flagged"] as const; const validStatuses = ["clean", "warn", "flagged"] as const;
@@ -120,7 +149,7 @@ export function parseModerationResponse(
const analysisStr = analysis ? String(analysis) : ""; const analysisStr = analysis ? String(analysis) : "";
return { return {
messageId: message_id, messageId: finalId,
status: status as "clean" | "warn" | "flagged", status: status as "clean" | "warn" | "flagged",
flags: flagsArray, flags: flagsArray,
score: numScore, score: numScore,
@@ -128,13 +157,18 @@ export function parseModerationResponse(
}; };
}); });
const filteredResults = results.filter(
(r): r is AnalysisResult => r !== null,
);
// Check that all target IDs were found // Check that all target IDs were found
const missingIds = targetIds.filter((id) => !foundIds.has(id)); const missingIds = targetIds.filter((id) => !foundIds.has(id));
if (missingIds.length > 0) { if (missingIds.length > 0) {
throw new Error(`Missing target ids in response: ${missingIds.join(", ")}`); log.warn({ missingIds }, "Some target IDs missing in response");
throw new Error(`Missing target IDs: ${missingIds.join(",")}`);
} }
return results; return filteredResults;
} }
interface ModerationInput { interface ModerationInput {
@@ -174,8 +208,11 @@ Context: ${contextText}
Messages to analyze: Messages to analyze:
${messagesText} ${messagesText}
For each message, respond with a JSON object containing a "results" array. Each result must have: For each message, respond with a JSON object containing a "results" array.
- message_id: the message ID CRITICAL: You MUST return the "message_id" EXACTLY as provided in the input, and it MUST be wrapped in double quotes as a STRING. Do not treat IDs as numbers.
Each result must have:
- message_id: the message ID (STRING, exactly as provided)
- status: "clean", "warn", or "flagged" - status: "clean", "warn", or "flagged"
- flags: array of violation flags (e.g., ["spam", "hate_speech"]) - flags: array of violation flags (e.g., ["spam", "hate_speech"])
- score: confidence score from 0 to 1 - score: confidence score from 0 to 1
@@ -213,13 +250,44 @@ Return ONLY valid JSON, no other text.`;
}), }),
}, },
); );
// Read the response body once (either text() or json()), then reuse it.
if (!response.ok) { let rawBody: string | undefined = undefined;
const text = await response.text(); if (typeof response.text === "function") {
throw new Error(`LLM API error ${response.status}: ${text}`); try {
rawBody = await response.text();
} catch {
rawBody = undefined;
}
} else if (typeof response.json === "function") {
try {
const j = await response.json();
rawBody = JSON.stringify(j);
} catch {
rawBody = undefined;
}
} }
return response.json(); if (!response.ok) {
throw new Error(
`LLM API error ${response.status}: ${rawBody ?? "(no body)"}`,
);
}
if (!rawBody) {
throw new Error("Empty LLM response");
}
// Try to parse the body as JSON, with fallback to scanning for an object
try {
return JSON.parse(rawBody);
} catch (e) {
const start = rawBody.indexOf("{");
const end = rawBody.lastIndexOf("}");
if (start !== -1 && end !== -1 && end > start) {
return JSON.parse(rawBody.substring(start, end + 1));
}
throw e;
}
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }

View File

@@ -123,15 +123,6 @@ export async function captureMessage(
} }
} }
} }
logger.info(
{
messageId: message.id,
channelId: message.channelId,
attachmentCount: message.attachments.size,
},
"Message captured",
);
} }
export function registerMessageCapture(client: Client): void { export function registerMessageCapture(client: Client): void {
@@ -206,8 +197,6 @@ export function registerMessageCapture(client: Client): void {
deleted_at: deletedAt, deleted_at: deletedAt,
}); });
} }
logger.info({ messageId: message.id }, "Message deletion captured");
} catch (error) { } catch (error) {
logger.error( logger.error(
{ {
@@ -218,6 +207,4 @@ export function registerMessageCapture(client: Client): void {
); );
} }
}); });
logger.info("Message capture handlers registered");
} }

View File

@@ -61,11 +61,6 @@ export async function insertMessage(message: MessageRecord): Promise<void> {
try { try {
const database = db(); const database = db();
await database.insert(messagesTable).values(message).onConflictDoNothing(); await database.insert(messagesTable).values(message).onConflictDoNothing();
logger.debug(
{ messageId: message.id, channelId: message.channel_id },
"Message inserted",
);
} catch (error) { } catch (error) {
logger.error( logger.error(
{ {
@@ -94,12 +89,7 @@ export async function upsertMessageForCapture(
.onConflictDoNothing() .onConflictDoNothing()
.returning({ id: messagesTable.id }); .returning({ id: messagesTable.id });
const inserted = rows.length > 0; return rows.length > 0;
logger.debug(
{ messageId: message.id, channelId: message.channel_id, inserted },
inserted ? "Message inserted for capture" : "Message already captured",
);
return inserted;
} catch (error) { } catch (error) {
logger.error( logger.error(
{ {
@@ -134,8 +124,6 @@ export async function updateMessageAsEdited(
ai_error: null, ai_error: null,
}) })
.where(eq(messagesTable.id, messageId)); .where(eq(messagesTable.id, messageId));
logger.debug({ messageId }, "Message marked as edited");
} catch (error) { } catch (error) {
logger.error( logger.error(
{ {
@@ -161,8 +149,6 @@ export async function updateMessageAsDeleted(
type: "deleted", type: "deleted",
}) })
.where(eq(messagesTable.id, messageId)); .where(eq(messagesTable.id, messageId));
logger.debug({ messageId }, "Message marked as deleted");
} catch (error) { } catch (error) {
logger.error( logger.error(
{ {
@@ -217,11 +203,6 @@ export async function insertAttachment(
.insert(attachmentsTable) .insert(attachmentsTable)
.values(attachment) .values(attachment)
.onConflictDoNothing(); .onConflictDoNothing();
logger.debug(
{ attachmentId: attachment.id, messageId: attachment.message_id },
"Attachment inserted",
);
} catch (error) { } catch (error) {
logger.error( logger.error(
{ {
@@ -282,11 +263,6 @@ export async function updateAttachmentAsUploaded(
uploaded_at: uploadedAt, uploaded_at: uploadedAt,
}) })
.where(eq(attachmentsTable.id, attachmentId)); .where(eq(attachmentsTable.id, attachmentId));
logger.debug(
{ attachmentId, uploadedUrl },
"Attachment marked as uploaded",
);
} catch (error) { } catch (error) {
logger.error( logger.error(
{ {
@@ -312,8 +288,6 @@ export async function updateAttachmentAsFailedUpload(
upload_error: error, upload_error: error,
}) })
.where(eq(attachmentsTable.id, attachmentId)); .where(eq(attachmentsTable.id, attachmentId));
logger.debug({ attachmentId, error }, "Attachment marked as failed upload");
} catch (error) { } catch (error) {
logger.error( logger.error(
{ {

View File

@@ -93,7 +93,12 @@ export interface AnalysisResult {
} }
export type MediaMode = "music" | "screen"; export type MediaMode = "music" | "screen";
export type MediaSourceKind = "url" | "local"; export type MediaSourceKind =
| "url"
| "local"
| "youtube"
| "spotify"
| "search";
export type MediaQueueItemStatus = "queued" | "playing" | "failed"; export type MediaQueueItemStatus = "queued" | "playing" | "failed";
export interface MediaQueueItem { export interface MediaQueueItem {
@@ -109,6 +114,7 @@ export interface MediaQueueItem {
export interface MediaState { export interface MediaState {
playing: boolean; playing: boolean;
musicVolume: number;
current: MediaQueueItem | null; current: MediaQueueItem | null;
queue: MediaQueueItem[]; queue: MediaQueueItem[];
} }

View File

@@ -2,15 +2,23 @@ import { Readable } from "node:stream";
import { import {
AudioPlayer, AudioPlayer,
AudioPlayerStatus, AudioPlayerStatus,
type AudioResource,
createAudioPlayer, createAudioPlayer,
createAudioResource, createAudioResource,
StreamType, StreamType,
VoiceConnection, VoiceConnection,
} from "@discordjs/voice"; } from "@discordjs/voice";
import type {
DiscordPlayOptions,
DiscordPlayerOwner,
} from "./media/mediaTypes";
export class DiscordPlayer { export class DiscordPlayer {
private player: AudioPlayer; private player: AudioPlayer;
private connection: VoiceConnection | null = null; private connection: VoiceConnection | null = null;
private owner: DiscordPlayerOwner = "none";
private resource: AudioResource | null = null;
private musicVolume = 1;
constructor() { constructor() {
this.player = createAudioPlayer(); this.player = createAudioPlayer();
@@ -21,6 +29,8 @@ export class DiscordPlayer {
this.player.on("error", (error) => { this.player.on("error", (error) => {
console.error(`[player] Error: ${error.message}`); console.error(`[player] Error: ${error.message}`);
this.owner = "none";
this.resource = null;
}); });
} }
@@ -29,30 +39,97 @@ export class DiscordPlayer {
this.connection.subscribe(this.player); this.connection.subscribe(this.player);
} }
public getOwner(): DiscordPlayerOwner {
return this.owner;
}
public isConnected(): boolean { public isConnected(): boolean {
return this.connection !== null; return this.connection !== null;
} }
public playStream(stream: Readable) { public playStream(
console.log("[player] Starting new audio stream..."); stream: Readable,
owner: DiscordPlayerOwner,
options: DiscordPlayOptions = {},
) {
if (owner === "none") {
throw new Error("Discord audio player owner is required");
}
this.assertOwnerAvailable(owner);
const resource = createAudioResource(stream, { const resource = createAudioResource(stream, {
inputType: StreamType.OggOpus, inputType: options.inputType ?? StreamType.OggOpus,
inlineVolume: options.inlineVolume ?? false,
}); });
if (this.owner === owner) {
this.player.stop();
}
this.resource = resource;
this.owner = owner;
if (owner === "music") {
const nextVolume =
options.volume !== undefined
? this.normalizeVolume(options.volume)
: this.musicVolume;
this.musicVolume = nextVolume;
this.setResourceVolume(nextVolume);
}
this.player.play(resource); this.player.play(resource);
this.connection?.subscribe(this.player);
} }
public pause() { public getStatus(): AudioPlayerStatus {
return this.player.state.status;
}
public pause(owner?: DiscordPlayerOwner) {
if (!this.canControl(owner)) return;
this.player.pause(true); this.player.pause(true);
} }
public unpause() { public unpause(owner?: DiscordPlayerOwner): boolean {
this.player.unpause(); if (!this.canControl(owner)) return false;
return this.player.unpause();
} }
public stop() { public stop(owner?: DiscordPlayerOwner) {
if (!this.canControl(owner)) return;
this.player.stop(); this.player.stop();
this.owner = "none";
this.resource = null;
}
public getMusicVolume(): number {
return this.musicVolume;
}
public setMusicVolume(volume: number): void {
const nextVolume = this.normalizeVolume(volume);
this.musicVolume = nextVolume;
if (this.owner === "music") {
this.setResourceVolume(nextVolume);
}
}
private assertOwnerAvailable(owner: DiscordPlayerOwner): void {
if (this.owner !== "none" && this.owner !== owner) {
throw new Error(`Discord audio player is owned by ${this.owner}`);
}
}
private canControl(owner?: DiscordPlayerOwner): boolean {
return !owner || this.owner === "none" || this.owner === owner;
}
private normalizeVolume(volume: number): number {
if (!Number.isFinite(volume)) return this.musicVolume;
return Math.max(0, Math.min(1, volume));
}
private setResourceVolume(volume: number): void {
if (!this.resource?.volume) return;
this.resource.volume.setVolume(volume);
} }
} }

View File

@@ -20,6 +20,11 @@ import {
createSegmentMetadata, createSegmentMetadata,
} from "./recorder/metadata"; } from "./recorder/metadata";
import { SegmentManager } from "./recorder/segment"; import { SegmentManager } from "./recorder/segment";
import {
createRecordingSession,
finalizeRecordingSession,
type RecordingSession,
} from "./recorder/sessionRecording";
import { retryWithBackoff } from "./retry"; import { retryWithBackoff } from "./retry";
import type { PcmBroadcaster } from "./types"; import type { PcmBroadcaster } from "./types";
@@ -32,6 +37,21 @@ if (!fs.existsSync(recordingsDir)) {
fs.mkdirSync(recordingsDir, { recursive: true }); fs.mkdirSync(recordingsDir, { recursive: true });
} }
const activeSessions = new Map<string, RecordingSession>();
export function resetActiveSessions(): void {
activeSessions.clear();
}
function finalizeActiveRecordingSession(guildId: string): void {
const session = activeSessions.get(guildId);
if (!session) return;
activeSessions.delete(guildId);
finalizeRecordingSession(session).catch((error) => {
logger.error({ error }, "Failed to finalize recording session");
});
}
/** /**
* Join ke voice channel dan mulai merekam semua user yang bicara. * Join ke voice channel dan mulai merekam semua user yang bicara.
*/ */
@@ -78,6 +98,17 @@ export async function startRecording(
}, },
); );
logger.info("Connected to voice channel. Recording started"); logger.info("Connected to voice channel. Recording started");
// Create recording session after connection is ready
const sessionStartTime = Date.now();
const session = createRecordingSession({
guildId: channel.guild.id,
channelId: channel.id,
channelName: channel.name,
startTime: sessionStartTime,
recordingsDir,
});
activeSessions.set(channel.guild.id, session);
} catch (err) { } catch (err) {
logger.error({ error: err }, "Failed to connect to voice channel"); logger.error({ error: err }, "Failed to connect to voice channel");
connection.destroy(); connection.destroy();
@@ -89,8 +120,12 @@ export async function startRecording(
// Dengarkan siapapun yang mulai bicara // Dengarkan siapapun yang mulai bicara
receiver.speaking.on("start", async (userId) => { receiver.speaking.on("start", async (userId) => {
if (userId === client.user?.id) return;
const userMetadata = await collectUserMetadata(client, userId, channel); const userMetadata = await collectUserMetadata(client, userId, channel);
logger.info( if (userMetadata.bot) return;
logger.debug(
{ userId, username: userMetadata.username }, { userId, username: userMetadata.username },
"Voice activity detected", "Voice activity detected",
); );
@@ -105,9 +140,6 @@ export async function startRecording(
// Jangan record kalau sudah ada stream aktif untuk user ini // Jangan record kalau sudah ada stream aktif untuk user ini
if (receiver.subscriptions.has(userId)) return; if (receiver.subscriptions.has(userId)) return;
const timestamp = Date.now();
const sessionStartTime = timestamp;
const sessionId = `${userId}-${sessionStartTime}`;
const userDir = path.join(recordingsDir, userId); const userDir = path.join(recordingsDir, userId);
if (!fs.existsSync(userDir)) { if (!fs.existsSync(userDir)) {
fs.mkdirSync(userDir, { recursive: true }); fs.mkdirSync(userDir, { recursive: true });
@@ -145,16 +177,28 @@ export async function startRecording(
}, },
}); });
const activeSession = activeSessions.get(channel.guild.id);
let currentSegment = segmentManager.open(oggPacketStream); let currentSegment = segmentManager.open(oggPacketStream);
currentSegment.out.on("finish", () => { currentSegment.out.on("finish", () => {
if (config.VERBOSE) { if (config.VERBOSE) {
logger.info({ filename: currentSegment.filename }, "Segment saved"); logger.info({ filename: currentSegment.filename }, "Segment saved");
} }
const endTime = currentSegment.endTime ?? Date.now();
if (activeSession) {
activeSession.registerSegment({
user: userMetadata,
oggPath: currentSegment.filename,
jsonPath: currentSegment.jsonFilename,
startTime: currentSegment.startTime,
endTime,
});
}
const metadata = createSegmentMetadata( const metadata = createSegmentMetadata(
userMetadata, userMetadata,
currentSegment, currentSegment,
sessionId, activeSession?.sessionId ?? `${userId}-0`,
sessionStartTime, activeSession?.sessionId ?? `${channel.guild.id}-${channel.id}-0`,
activeSession?.startTime ?? 0,
config.RECORDING_SEGMENT_MS, config.RECORDING_SEGMENT_MS,
); );
fs.writeFileSync( fs.writeFileSync(
@@ -236,6 +280,7 @@ export async function startRecording(
}); });
connection.on(VoiceConnectionStatus.Destroyed, () => { connection.on(VoiceConnectionStatus.Destroyed, () => {
finalizeActiveRecordingSession(channel.guild.id);
if (config.VERBOSE) { if (config.VERBOSE) {
logger.info("Voice connection destroyed"); logger.info("Voice connection destroyed");
} }
@@ -257,4 +302,6 @@ export function stopRecording(guildId: string): void {
} else { } else {
logger.warn("No active connection to stop"); logger.warn("No active connection to stop");
} }
finalizeActiveRecordingSession(guildId);
} }

View File

@@ -55,6 +55,7 @@ export function createSegmentMetadata(
user: UserMetadata, user: UserMetadata,
segment: SegmentState, segment: SegmentState,
sessionId: string, sessionId: string,
recordingSessionId: string,
sessionStartTime: number, sessionStartTime: number,
recordingSegmentMs: number, recordingSegmentMs: number,
): SegmentMetadata { ): SegmentMetadata {
@@ -62,6 +63,7 @@ export function createSegmentMetadata(
return { return {
...user, ...user,
sessionId, sessionId,
recordingSessionId,
sessionStartTime, sessionStartTime,
segmentIndex: segment.index, segmentIndex: segment.index,
segmentMs: recordingSegmentMs, segmentMs: recordingSegmentMs,

View File

@@ -0,0 +1,192 @@
import fs from "node:fs";
import path from "node:path";
import {
buildMuxFfmpegArgs,
runFfmpeg as defaultRunFfmpeg,
} from "../audio/ffmpegProcess";
import type { UserMetadata } from "../types";
export type SessionRecordingStatus =
| "pending"
| "completed"
| "failed"
| "empty";
export interface RecordingSessionOptions {
guildId: string;
channelId: string;
channelName: string;
startTime: number;
recordingsDir: string;
}
export interface SessionSegmentInput {
user: UserMetadata;
oggPath: string;
jsonPath: string;
startTime: number;
endTime: number;
}
export interface SessionParticipant {
userId: string;
username: string;
tag: string;
displayName: string;
avatarUrl: string;
}
export interface SessionSegmentRef {
userId: string;
oggPath: string;
jsonPath: string;
startTime: number;
endTime: number;
durationMs: number;
offsetMs: number;
}
export interface SessionRecordingMetadata {
sessionId: string;
guildId: string;
channelId: string;
channelName: string;
startTime: number;
endTime: number;
durationMs: number;
status: SessionRecordingStatus;
outputFile: string | null;
participants: SessionParticipant[];
segments: SessionSegmentRef[];
error?: string;
}
export interface RecordingSession {
readonly sessionId: string;
readonly recordingsDir: string;
readonly startTime: number;
registerSegment(input: SessionSegmentInput): void;
snapshot(endTime: number): SessionRecordingMetadata;
}
export interface FinalizeRecordingSessionDependencies {
endTime?: number;
mkdir?: (dir: string) => void;
writeJson?: (file: string, metadata: SessionRecordingMetadata) => void;
runFfmpeg?: (args: string[]) => Promise<void>;
}
export function createRecordingSession(
options: RecordingSessionOptions,
): RecordingSession {
const sessionId = `${options.guildId}-${options.channelId}-${options.startTime}`;
const participants = new Map<string, SessionParticipant>();
const segments: SessionSegmentRef[] = [];
return {
sessionId,
recordingsDir: options.recordingsDir,
startTime: options.startTime,
registerSegment(input: SessionSegmentInput): void {
participants.set(input.user.userId, {
userId: input.user.userId,
username: input.user.username,
tag: input.user.tag,
displayName: input.user.displayName,
avatarUrl: input.user.avatarUrl,
});
segments.push({
userId: input.user.userId,
oggPath: input.oggPath,
jsonPath: input.jsonPath,
startTime: input.startTime,
endTime: input.endTime,
durationMs: input.endTime - input.startTime,
offsetMs: input.startTime - options.startTime,
});
},
snapshot(endTime: number): SessionRecordingMetadata {
return {
sessionId,
guildId: options.guildId,
channelId: options.channelId,
channelName: options.channelName,
startTime: options.startTime,
endTime,
durationMs: endTime - options.startTime,
status: "pending",
outputFile: null,
participants: Array.from(participants.values()),
segments: [...segments],
};
},
};
}
export function buildSessionMuxFilter(
segments: Array<{ startTime: number }>,
sessionStartTime: number,
): string {
const filters = segments.map((segment, index) => {
const delayMs = Math.max(0, segment.startTime - sessionStartTime);
return `[${index}:a]adelay=${delayMs}|${delayMs}[pad${index}]`;
});
const inputs = segments.map((_, index) => `[pad${index}]`).join("");
filters.push(
`${inputs}amix=inputs=${segments.length}:dropout_transition=0[out]`,
);
return filters.join(";");
}
export async function finalizeRecordingSession(
session: RecordingSession,
dependencies: FinalizeRecordingSessionDependencies = {},
): Promise<void> {
const endTime = dependencies.endTime ?? Date.now();
const sessionDir = path.join(
session.recordingsDir,
"sessions",
session.sessionId,
);
const outputFile = path.join(sessionDir, "full.ogg");
const metadataFile = path.join(sessionDir, "session.json");
const mkdir =
dependencies.mkdir ?? ((dir) => fs.mkdirSync(dir, { recursive: true }));
const writeJson =
dependencies.writeJson ??
((file, metadata) =>
fs.writeFileSync(file, JSON.stringify(metadata, null, 2)));
const runFfmpeg = dependencies.runFfmpeg ?? defaultRunFfmpeg;
mkdir(sessionDir);
const metadata = session.snapshot(endTime);
if (metadata.segments.length === 0) {
writeJson(metadataFile, { ...metadata, status: "empty" });
return;
}
try {
await runFfmpeg(
buildMuxFfmpegArgs({
inputs: metadata.segments.map((segment) => segment.oggPath),
filter: buildSessionMuxFilter(metadata.segments, metadata.startTime),
output: outputFile,
codec: "libopus",
}),
);
writeJson(metadataFile, {
...metadata,
status: "completed",
outputFile,
});
} catch (error) {
writeJson(metadataFile, {
...metadata,
status: "failed",
error: error instanceof Error ? error.message : String(error),
});
}
}

View File

@@ -1,15 +1,12 @@
import type { Router } from "express"; import type { Router } from "express";
import express from "express"; import express from "express";
import { AppError } from "../errors"; import { AppError } from "../errors";
import { createChildLogger } from "../logger";
import { import {
getAnalysisQueueStatus, getAnalysisQueueStatus,
queueMessageAnalysis, queueMessageAnalysis,
} from "../moderation/aiAnalyzer"; } from "../moderation/aiAnalyzer";
import { getMessageById } from "../moderation/messageStore"; import { getMessageById } from "../moderation/messageStore";
const logger = createChildLogger("analysis-routes");
export function createAnalysisRoutes(): Router { export function createAnalysisRoutes(): Router {
const router = express.Router(); const router = express.Router();
@@ -41,8 +38,6 @@ export function createAnalysisRoutes(): Router {
// Queue for analysis // Queue for analysis
await queueMessageAnalysis(id); await queueMessageAnalysis(id);
logger.info({ messageId: id }, "Message queued for re-analysis");
res.json({ res.json({
success: true, success: true,
messageId: id, messageId: id,

View File

@@ -1,55 +1,118 @@
import type { Router } from "express"; import type { NextFunction, Request, Response, Router } from "express";
import express from "express"; import express from "express";
import { AppError } from "../errors"; import { AppError } from "../errors";
import type { MediaController } from "../media/mediaController"; import type { MediaController } from "../media/mediaController";
import type { MediaMode } from "../media/mediaTypes";
export type MediaRouteController = Pick< export type MediaRouteController = Pick<
MediaController, MediaController,
"getState" | "queue" | "skip" | "stop" "getState" | "queue" | "skip" | "stop" | "setMusicVolume"
>; >;
export function createMediaRoutes(controller: MediaRouteController): Router { export interface MediaRouteOptions {
adminPassword?: string;
}
export function createMediaRoutes(
controller: MediaRouteController,
options: MediaRouteOptions = {},
): Router {
const router = express.Router(); const router = express.Router();
const { adminPassword } = options;
router.get("/media/status", (_req, res, next) => { const adminAuth = (req: Request, res: Response, next: NextFunction) => {
try { if (!adminPassword) return next();
res.json(controller.getState()); const authHeader = req.headers["x-admin-password"];
} catch (error) { if (authHeader === adminPassword) {
next(error); next();
} else {
res.status(401).json({ error: "Unauthorized access to admin features" });
} }
}); };
router.post("/media/queue", async (req, res, next) => { // Apply admin auth as router-level middleware so route stack ordering
try { // remains predictable for tests that inspect route handlers.
const { source } = req.body as { source?: string }; router.use(adminAuth);
if (!source) {
throw new AppError( router.get(
"Media source is required", "/media/status",
"MISSING_MEDIA_SOURCE", (_req: Request, res: Response, next: NextFunction) => {
400, try {
); res.json(controller.getState());
} catch (error) {
next(error);
} }
res.json(await controller.queue(source)); },
} catch (error) { );
next(error);
}
});
router.post("/media/skip", async (_req, res, next) => { router.post(
try { "/media/queue",
res.json(await controller.skip()); async (req: Request, res: Response, next: NextFunction) => {
} catch (error) { try {
next(error); const { source, mode = "music" } = req.body as {
} source?: string;
}); mode?: MediaMode;
};
if (!source) {
throw new AppError(
"Media source is required",
"MISSING_MEDIA_SOURCE",
400,
);
}
if (mode !== "music" && mode !== "screen") {
throw new AppError("Invalid media mode", "INVALID_MEDIA_MODE", 400);
}
res.json(await controller.queue(source, { mode }));
} catch (error) {
next(error);
}
},
);
router.post("/media/stop", async (_req, res, next) => { router.post(
try { "/media/skip",
res.json(await controller.stop()); async (_req: Request, res: Response, next: NextFunction) => {
} catch (error) { try {
next(error); res.json(await controller.skip());
} } catch (error) {
}); next(error);
}
},
);
router.post(
"/media/stop",
async (_req: Request, res: Response, next: NextFunction) => {
try {
res.json(await controller.stop());
} catch (error) {
next(error);
}
},
);
router.post(
"/media/volume",
async (req: Request, res: Response, next: NextFunction) => {
try {
const { volume } = req.body as { volume?: number };
if (typeof volume !== "number" || Number.isNaN(volume)) {
throw new AppError("Volume is required", "INVALID_VOLUME", 400);
}
if (volume < 0 || volume > 1) {
throw new AppError(
"Volume must be between 0 and 1",
"INVALID_VOLUME",
400,
);
}
res.json(await controller.setMusicVolume(volume));
} catch (error) {
next(error);
}
},
);
return router; return router;
} }

View File

@@ -45,33 +45,34 @@ export function createSyncRoutes(client: Client): Router {
} }
if (shouldSkipRecentBacklogSync(guildId, channelId)) { if (shouldSkipRecentBacklogSync(guildId, channelId)) {
logger.debug({ guildId, channelId }, "Skipping recent backlog sync");
res.json({ res.json({
success: true, success: true,
channelId, channelId,
messagesSync: 0, messagesSync: 0,
queued: false,
skipped: true, skipped: true,
}); });
return; return;
} }
logger.info({ guildId, channelId }, "Starting backlog sync"); syncSelectedChannelBacklog(client, guildId, channelId)
.then(() => {})
const count = await syncSelectedChannelBacklog( .catch((error) => {
client, logger.warn(
guildId, {
channelId, guildId,
); channelId,
error: error instanceof Error ? error.message : String(error),
logger.info( },
{ guildId, channelId, messagesSync: count }, "Backlog sync failed",
"Backlog sync complete", );
); });
res.json({ res.json({
success: true, success: true,
channelId, channelId,
messagesSync: count, messagesSync: 0,
queued: true,
skipped: false, skipped: false,
}); });
} catch (error) { } catch (error) {

View File

@@ -9,7 +9,7 @@ export interface SharedUIState {
selectedVoiceChannel: string; selectedVoiceChannel: string;
selectedTextGuild: string; selectedTextGuild: string;
selectedTextChannel: string; selectedTextChannel: string;
activeTab: "voice" | "text"; activeTab: "voice" | "messages" | "media" | "review";
isListening: boolean; isListening: boolean;
isStreaming: boolean; isStreaming: boolean;
} }

View File

@@ -1,4 +1,4 @@
import type { Router } from "express"; import type { NextFunction, Request, Response, Router } from "express";
import express from "express"; import express from "express";
import { AppError } from "../errors"; import { AppError } from "../errors";
import { createChildLogger } from "../logger"; import { createChildLogger } from "../logger";
@@ -12,6 +12,7 @@ export interface VoiceRouteOptions {
voiceController: VoiceController; voiceController: VoiceController;
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState; patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
broadcaster: ModerationBroadcaster; broadcaster: ModerationBroadcaster;
adminPassword?: string;
} }
export function createVoiceRoutes( export function createVoiceRoutes(
@@ -25,6 +26,7 @@ export function createVoiceRoutes(
| ((patch: Partial<SharedUIState>) => SharedUIState) | ((patch: Partial<SharedUIState>) => SharedUIState)
| undefined; | undefined;
let broadcaster: ModerationBroadcaster | undefined; let broadcaster: ModerationBroadcaster | undefined;
let adminPassword: string | undefined;
if ("connect" in options && "disconnect" in options) { if ("connect" in options && "disconnect" in options) {
// Old signature: just VoiceController // Old signature: just VoiceController
@@ -35,10 +37,21 @@ export function createVoiceRoutes(
voiceController = opts.voiceController; voiceController = opts.voiceController;
patchSharedUIState = opts.patchSharedUIState; patchSharedUIState = opts.patchSharedUIState;
broadcaster = opts.broadcaster; broadcaster = opts.broadcaster;
adminPassword = opts.adminPassword;
} }
const adminAuth = (req: Request, res: Response, next: NextFunction) => {
if (!adminPassword) return next();
const authHeader = req.headers["x-admin-password"];
if (authHeader === adminPassword) {
next();
} else {
res.status(401).json({ error: "Unauthorized access to admin features" });
}
};
// GET /api/status - Get voice connection status // GET /api/status - Get voice connection status
router.get("/status", (_req, res, next) => { router.get("/status", (_req: Request, res: Response, next: NextFunction) => {
try { try {
const status = voiceController.getStatus(); const status = voiceController.getStatus();
res.json(status); res.json(status);
@@ -48,7 +61,7 @@ export function createVoiceRoutes(
}); });
// GET /api/guilds - List available guilds // GET /api/guilds - List available guilds
router.get("/guilds", (_req, res, next) => { router.get("/guilds", (_req: Request, res: Response, next: NextFunction) => {
try { try {
const guilds = voiceController.listGuilds(); const guilds = voiceController.listGuilds();
res.json(guilds); res.json(guilds);
@@ -58,109 +71,111 @@ export function createVoiceRoutes(
}); });
// GET /api/guilds/:guildId/voice-channels - List voice channels in a guild // GET /api/guilds/:guildId/voice-channels - List voice channels in a guild
router.get("/guilds/:guildId/voice-channels", async (req, res, next) => { router.get(
try { "/guilds/:guildId/voice-channels",
const { guildId } = req.params; async (req: Request, res: Response, next: NextFunction) => {
try {
const { guildId } = req.params;
if (!guildId) { if (!guildId) {
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400); throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
}
const channels = await voiceController.listVoiceChannels(
guildId as string,
);
res.json(channels);
} catch (error) {
next(error);
} }
},
const channels = await voiceController.listVoiceChannels(guildId); );
res.json(channels);
} catch (error) {
next(error);
}
});
// GET /api/guilds/:guildId/channels - List text channels in a guild // GET /api/guilds/:guildId/channels - List text channels in a guild
router.get("/guilds/:guildId/channels", async (req, res, next) => { router.get(
try { "/guilds/:guildId/channels",
const { guildId } = req.params; async (req: Request, res: Response, next: NextFunction) => {
try {
const { guildId } = req.params;
if (!guildId) { if (!guildId) {
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400); throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
}
const channels = await voiceController.listWatchableChannels(
guildId as string,
);
res.json(channels);
} catch (error) {
next(error);
} }
},
const channels = await voiceController.listWatchableChannels(guildId); );
res.json(channels);
} catch (error) {
next(error);
}
});
// GET /api/guilds/:guildId/threads - List threads in a guild
router.get("/guilds/:guildId/threads", async (req, res, next) => {
try {
const { guildId } = req.params;
if (!guildId) {
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
}
const threads = await voiceController.listThreads(guildId);
res.json(threads);
} catch (error) {
next(error);
}
});
// POST /api/connect - Connect to a voice channel // POST /api/connect - Connect to a voice channel
router.post("/connect", async (req, res, next) => { router.post(
try { "/connect",
const { guildId, channelId } = req.body as { adminAuth,
guildId?: string; async (req: Request, res: Response, next: NextFunction) => {
channelId?: string; try {
}; const { guildId, channelId } = req.body as {
guildId?: string;
channelId?: string;
};
if (!guildId || !channelId) { if (!guildId || !channelId) {
throw new AppError( throw new AppError(
"guildId and channelId are required", "guildId and channelId are required",
"MISSING_CONNECT_FIELDS", "MISSING_CONNECT_FIELDS",
400, 400,
); );
}
logger.info({ guildId, channelId }, "Connecting to voice channel");
const status = await voiceController.connect(guildId, channelId);
// Update UI state and broadcast to connected clients
if (patchSharedUIState && broadcaster) {
const updatedState = patchSharedUIState({
selectedVoiceGuild: guildId,
selectedVoiceChannel: channelId,
});
broadcaster.uiState(updatedState);
}
res.json(status);
} catch (error) {
next(error);
} }
},
logger.info({ guildId, channelId }, "Connecting to voice channel"); );
const status = await voiceController.connect(guildId, channelId);
// Update UI state and broadcast to connected clients
if (patchSharedUIState && broadcaster) {
const updatedState = patchSharedUIState({
selectedVoiceGuild: guildId,
selectedVoiceChannel: channelId,
});
broadcaster.uiState(updatedState);
}
res.json(status);
} catch (error) {
next(error);
}
});
// POST /api/disconnect - Disconnect from voice channel // POST /api/disconnect - Disconnect from voice channel
router.post("/disconnect", async (_req, res, next) => { router.post(
try { "/disconnect",
logger.info("Disconnecting from voice channel"); adminAuth,
async (_req: Request, res: Response, next: NextFunction) => {
try {
logger.info("Disconnecting from voice channel");
const status = await voiceController.disconnect(); const status = await voiceController.disconnect();
// Update UI state and broadcast to connected clients // Update UI state and broadcast to connected clients
if (patchSharedUIState && broadcaster) { if (patchSharedUIState && broadcaster) {
const updatedState = patchSharedUIState({ const updatedState = patchSharedUIState({
selectedVoiceGuild: "", selectedVoiceGuild: "",
selectedVoiceChannel: "", selectedVoiceChannel: "",
}); });
broadcaster.uiState(updatedState); broadcaster.uiState(updatedState);
}
res.json(status);
} catch (error) {
next(error);
} }
},
res.json(status); );
} catch (error) {
next(error);
}
});
return router; return router;
} }

Some files were not shown because too many files have changed in this diff Show More