Compare commits
58 Commits
6859eb3f50
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8584030633 | ||
|
|
f35710db3f | ||
|
|
0c7930bd01 | ||
|
|
e22e620bae | ||
|
|
eda32720c8 | ||
|
|
c0f66c78a3 | ||
|
|
b8a6f40b1b | ||
|
|
4931e6d1ca | ||
|
|
a3e6c4695a | ||
|
|
6de5342703 | ||
|
|
5a926dbd17 | ||
|
|
7985efbef6 | ||
|
|
71889ab689 | ||
|
|
518577d79d | ||
|
|
d04093ec6e | ||
|
|
05feb697f0 | ||
|
|
a5b5ccf5b0 | ||
|
|
99ec528a03 | ||
|
|
7dedac2094 | ||
|
|
9b211f05cf | ||
|
|
4825dc6d4d | ||
|
|
9ad7d16a17 | ||
|
|
62d131cf14 | ||
|
|
82025a19b2 | ||
|
|
3c7d722973 | ||
|
|
70931576dc | ||
|
|
8e1f5adaa4 | ||
|
|
2744e7035b | ||
|
|
8b33af8286 | ||
|
|
d50ce8698f | ||
|
|
e32e092596 | ||
|
|
6ac4a5c11a | ||
|
|
119258c2b0 | ||
|
|
5abe5cc39f | ||
|
|
c954cc0406 | ||
|
|
95ea0cee75 | ||
|
|
2e30a063d2 | ||
|
|
6aeabc690f | ||
|
|
73901231c3 | ||
|
|
61b07e4b01 | ||
|
|
a97feb1e2a | ||
|
|
575302db57 | ||
|
|
76470a5129 | ||
|
|
ff2792d403 | ||
|
|
192f83d31d | ||
|
|
06b6db703c | ||
|
|
94e497b7a6 | ||
|
|
b00def2d4d | ||
|
|
dbae042279 | ||
|
|
c509f48f95 | ||
|
|
1e0a00d82d | ||
|
|
9e07a0a1f3 | ||
|
|
acb43b6dac | ||
|
|
93134a9793 | ||
|
|
2194d4a8b6 | ||
|
|
3b6bf49160 | ||
|
|
d42d3f8def | ||
|
|
ed438e6fc0 |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.test
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
recordings
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
test_out.nut
|
||||||
11
.env.example
11
.env.example
@@ -25,12 +25,21 @@ WEBSERVER_PORT=3000
|
|||||||
VOICE_CONNECTION_TIMEOUT_MS=15000
|
VOICE_CONNECTION_TIMEOUT_MS=15000
|
||||||
RECONNECT_TIMEOUT_MS=5000
|
RECONNECT_TIMEOUT_MS=5000
|
||||||
|
|
||||||
|
# Voice Recording Selection
|
||||||
|
# VOICE_GUILD_ID falls back to legacy GUILD_ID when omitted.
|
||||||
|
GUILD_ID=legacy_voice_guild_id
|
||||||
|
VOICE_GUILD_ID=voice_guild_id
|
||||||
|
VOICE_CHANNEL_ID=voice_channel_id
|
||||||
|
|
||||||
# Logging Configuration
|
# Logging Configuration
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# Moderation Configuration
|
# Moderation Configuration
|
||||||
MONITOR_GUILD_ID=your_guild_id_here
|
# TEXT_GUILD_ID falls back to legacy MONITOR_GUILD_ID when omitted.
|
||||||
|
MONITOR_GUILD_ID=legacy_text_guild_id
|
||||||
|
TEXT_GUILD_ID=text_guild_id
|
||||||
|
TEXT_CHANNEL_ID=text_channel_id
|
||||||
PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload
|
PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload
|
||||||
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
|
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
|
||||||
ATTACHMENT_MAX_SIZE_MB=100
|
ATTACHMENT_MAX_SIZE_MB=100
|
||||||
|
|||||||
30
.env.test
30
.env.test
@@ -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
35
.github/workflows/deploy.yml
vendored
Normal 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
1
.gitignore
vendored
@@ -5,3 +5,4 @@ dist/
|
|||||||
public/app/
|
public/app/
|
||||||
.muxer-queue.**
|
.muxer-queue.**
|
||||||
.claude/
|
.claude/
|
||||||
|
.env.test
|
||||||
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -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
36
Dockerfile
Normal 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"]
|
||||||
17
README.md
17
README.md
@@ -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
20
components.json
Normal 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
60
debug-screen.ts
Normal 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
45
deploy.sh
Executable 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
30
docker-compose.yml
Normal 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
|
||||||
1291
docs/superpowers/plans/2026-05-15-media-music-phase-1.md
Normal file
1291
docs/superpowers/plans/2026-05-15-media-music-phase-1.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
466
docs/superpowers/plans/2026-05-15-split-text-voice-selection.md
Normal file
466
docs/superpowers/plans/2026-05-15-split-text-voice-selection.md
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
# Split Text Voice Selection 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:** Separate text moderation guild/channel selection from voice recording guild/channel selection in config, backend state, and dashboard UI.
|
||||||
|
|
||||||
|
**Architecture:** Add explicit text and voice config keys while keeping legacy `MONITOR_GUILD_ID` and `GUILD_ID` as fallbacks. Split shared UI state into `selectedTextGuild`/`selectedTextChannel` and `selectedVoiceGuild`/`selectedVoiceChannel`, with backward-compatible migration from old persisted `selectedGuild`. Update capture/backlog to use text-specific settings and voice routes to update only voice-specific state.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Zod config, Express routes, Discord selfbot client, Vitest, static dashboard JavaScript.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify `src/config.ts`: add `TEXT_GUILD_ID`, `TEXT_CHANNEL_ID`, `VOICE_GUILD_ID`; derive effective text/voice IDs with legacy fallbacks.
|
||||||
|
- Modify `.env.example`: document split text/voice configuration.
|
||||||
|
- Modify `src/moderation/messageCapture.ts`: filter live capture by effective text guild and optional text channel.
|
||||||
|
- Modify `src/moderation/backlogSync.ts`: use effective text guild and optional text channel for readiness/on-demand sync.
|
||||||
|
- Modify `src/webserver.ts`: change `SharedUIState` to split text/voice guild fields and migrate old persisted state.
|
||||||
|
- Modify `src/routes/uiStateRoutes.ts`: update shared UI state type.
|
||||||
|
- Modify `src/routes/voiceRoutes.ts`: patch `selectedVoiceGuild` only on connect/disconnect.
|
||||||
|
- Modify `public/index.html`: add separate voice guild select and text guild select behavior.
|
||||||
|
- Tests: `tests/config.test.ts`, `tests/moderation/messageCapture.test.ts`, and a new UI state route/unit test if needed.
|
||||||
|
|
||||||
|
## Task 1: Split Config Defaults
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/config.ts`
|
||||||
|
- Modify: `.env.example`
|
||||||
|
- Test: `tests/config.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing config tests**
|
||||||
|
|
||||||
|
Add tests to `tests/config.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it("derives split text and voice guild defaults from legacy config", async () => {
|
||||||
|
process.env = {
|
||||||
|
...originalEnv,
|
||||||
|
DISCORD_TOKEN: "token",
|
||||||
|
MONITOR_GUILD_ID: "legacy-text-guild",
|
||||||
|
GUILD_ID: "legacy-voice-guild",
|
||||||
|
VOICE_CHANNEL_ID: "voice-channel",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { loadConfig } = await import("../src/config");
|
||||||
|
const config = loadConfig(process.env);
|
||||||
|
|
||||||
|
expect(config.TEXT_GUILD_ID).toBeUndefined();
|
||||||
|
expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("legacy-text-guild");
|
||||||
|
expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("legacy-voice-guild");
|
||||||
|
expect(config.VOICE_CHANNEL_ID).toBe("voice-channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses explicit split text and voice config before legacy values", async () => {
|
||||||
|
process.env = {
|
||||||
|
...originalEnv,
|
||||||
|
DISCORD_TOKEN: "token",
|
||||||
|
MONITOR_GUILD_ID: "legacy-text-guild",
|
||||||
|
GUILD_ID: "legacy-voice-guild",
|
||||||
|
TEXT_GUILD_ID: "text-guild",
|
||||||
|
TEXT_CHANNEL_ID: "text-channel",
|
||||||
|
VOICE_GUILD_ID: "voice-guild",
|
||||||
|
VOICE_CHANNEL_ID: "voice-channel",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { loadConfig } = await import("../src/config");
|
||||||
|
const config = loadConfig(process.env);
|
||||||
|
|
||||||
|
expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("text-guild");
|
||||||
|
expect(config.TEXT_CHANNEL_ID).toBe("text-channel");
|
||||||
|
expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("voice-guild");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run config tests red**
|
||||||
|
|
||||||
|
Run: `pnpm exec vitest run tests/config.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because `EFFECTIVE_TEXT_GUILD_ID` and `EFFECTIVE_VOICE_GUILD_ID` do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add split config fields and derived values**
|
||||||
|
|
||||||
|
In `src/config.ts`, add schema fields near legacy guild config:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
TEXT_GUILD_ID: z.string().min(1).optional(),
|
||||||
|
TEXT_CHANNEL_ID: z.string().min(1).optional(),
|
||||||
|
VOICE_GUILD_ID: z.string().min(1).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `loadConfig` to parse then return derived values:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const parsed = configSchema.parse(env);
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID,
|
||||||
|
EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `AppConfig` to include derived fields:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type AppConfig = z.infer<typeof configSchema> & {
|
||||||
|
EFFECTIVE_TEXT_GUILD_ID?: string;
|
||||||
|
EFFECTIVE_VOICE_GUILD_ID?: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `.env.example`**
|
||||||
|
|
||||||
|
Document:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Text moderation capture target. Falls back to MONITOR_GUILD_ID for compatibility.
|
||||||
|
TEXT_GUILD_ID=
|
||||||
|
TEXT_CHANNEL_ID=
|
||||||
|
|
||||||
|
# Voice recording default target. Falls back to GUILD_ID for compatibility.
|
||||||
|
VOICE_GUILD_ID=
|
||||||
|
VOICE_CHANNEL_ID=
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep existing legacy keys with notes rather than deleting them.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run config tests green**
|
||||||
|
|
||||||
|
Run: `pnpm exec vitest run tests/config.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 2: Apply Text Capture Guild/Channel Filtering
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/moderation/messageCapture.ts`
|
||||||
|
- Modify: `src/moderation/backlogSync.ts`
|
||||||
|
- Test: `tests/moderation/messageCapture.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing channel filter test**
|
||||||
|
|
||||||
|
In `tests/moderation/messageCapture.test.ts`, mock config before importing `captureMessage` if needed or add a new test file `tests/moderation/messageCaptureFilter.test.ts` that imports a new exported helper.
|
||||||
|
|
||||||
|
Preferred helper test: create `tests/moderation/messageCaptureFilter.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { shouldCaptureMessageLocation } from "../../src/moderation/messageCapture";
|
||||||
|
|
||||||
|
describe("shouldCaptureMessageLocation", () => {
|
||||||
|
it("matches only configured text guild and optional channel", () => {
|
||||||
|
expect(
|
||||||
|
shouldCaptureMessageLocation(
|
||||||
|
{ guildId: "guild-1", channelId: "channel-1" },
|
||||||
|
{ guildId: "guild-1", channelId: "channel-1" },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldCaptureMessageLocation(
|
||||||
|
{ guildId: "guild-1", channelId: "channel-2" },
|
||||||
|
{ guildId: "guild-1", channelId: "channel-1" },
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldCaptureMessageLocation(
|
||||||
|
{ guildId: "guild-2", channelId: "channel-1" },
|
||||||
|
{ guildId: "guild-1", channelId: "channel-1" },
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run filter test red**
|
||||||
|
|
||||||
|
Run: `pnpm exec vitest run tests/moderation/messageCaptureFilter.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because `shouldCaptureMessageLocation` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add capture filter helper**
|
||||||
|
|
||||||
|
In `src/moderation/messageCapture.ts`, export:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface TextCaptureTarget {
|
||||||
|
guildId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageLocationInput {
|
||||||
|
guildId?: string | null;
|
||||||
|
channelId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldCaptureMessageLocation(
|
||||||
|
message: MessageLocationInput,
|
||||||
|
target: TextCaptureTarget,
|
||||||
|
): boolean {
|
||||||
|
if (!message.guildId || message.guildId !== target.guildId) return false;
|
||||||
|
if (target.channelId && message.channelId !== target.channelId) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace event checks:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (
|
||||||
|
!shouldCaptureMessageLocation(message, {
|
||||||
|
guildId: config.EFFECTIVE_TEXT_GUILD_ID,
|
||||||
|
channelId: config.TEXT_CHANNEL_ID,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the same helper for `messageUpdate` and `messageDelete`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update backlog sync config**
|
||||||
|
|
||||||
|
In `src/moderation/backlogSync.ts`, replace readiness checks with `config.EFFECTIVE_TEXT_GUILD_ID` and log names with `TEXT_GUILD_ID`. If `config.TEXT_CHANNEL_ID` is present in `syncBacklogMessages`, verify the channel exists and call `syncSelectedChannelBacklog(client, guild.id, config.TEXT_CHANNEL_ID)` instead of only logging readiness.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run focused moderation tests**
|
||||||
|
|
||||||
|
Run: `pnpm exec vitest run tests/moderation/messageCapture.test.ts tests/moderation/messageCaptureFilter.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 3: Split Shared UI State
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/webserver.ts`
|
||||||
|
- Modify: `src/routes/uiStateRoutes.ts`
|
||||||
|
- Modify: `src/routes/voiceRoutes.ts`
|
||||||
|
- Test: create `tests/routes/uiStateRoutes.test.ts` if no existing route test fits.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write state migration test**
|
||||||
|
|
||||||
|
Create `tests/routes/uiStateRoutes.test.ts` with a pure helper import if extracted. Add helper in Task 3 implementation.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizeSharedUIState } from "../../src/webserver";
|
||||||
|
|
||||||
|
describe("normalizeSharedUIState", () => {
|
||||||
|
it("migrates legacy selectedGuild into split text and voice guilds", () => {
|
||||||
|
expect(
|
||||||
|
normalizeSharedUIState({
|
||||||
|
selectedGuild: "legacy-guild",
|
||||||
|
selectedVoiceChannel: "voice-channel",
|
||||||
|
selectedTextChannel: "text-channel",
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
selectedVoiceGuild: "legacy-guild",
|
||||||
|
selectedVoiceChannel: "voice-channel",
|
||||||
|
selectedTextGuild: "legacy-guild",
|
||||||
|
selectedTextChannel: "text-channel",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run state test red**
|
||||||
|
|
||||||
|
Run: `pnpm exec vitest run tests/routes/uiStateRoutes.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because `normalizeSharedUIState` does not exist/export.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update shared state types**
|
||||||
|
|
||||||
|
In `src/routes/uiStateRoutes.ts` and `src/webserver.ts`, replace `selectedGuild` with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selectedVoiceGuild: string;
|
||||||
|
selectedVoiceChannel: string;
|
||||||
|
selectedTextGuild: string;
|
||||||
|
selectedTextChannel: string;
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep request patch compatibility by allowing `selectedGuild?: string` in the normalization helper input.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add normalizer and use it after persistence load**
|
||||||
|
|
||||||
|
In `src/webserver.ts`, export:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function normalizeSharedUIState(value: Partial<SharedUIState> & { selectedGuild?: string }): SharedUIState {
|
||||||
|
const legacyGuild = value.selectedGuild ?? "";
|
||||||
|
return {
|
||||||
|
selectedVoiceGuild: value.selectedVoiceGuild ?? legacyGuild,
|
||||||
|
selectedVoiceChannel: value.selectedVoiceChannel ?? "",
|
||||||
|
selectedTextGuild: value.selectedTextGuild ?? legacyGuild,
|
||||||
|
selectedTextChannel: value.selectedTextChannel ?? "",
|
||||||
|
activeTab: value.activeTab === "text" ? "text" : "voice",
|
||||||
|
isListening: value.isListening ?? false,
|
||||||
|
isStreaming: value.isStreaming ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use it in `initializeSharedUIState()`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
sharedUIState = normalizeSharedUIState(
|
||||||
|
await getPersistedValue("web-ui-state", defaultSharedUIState),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `patchSharedUIState` to accept `selectedVoiceGuild`, `selectedVoiceChannel`, `selectedTextGuild`, `selectedTextChannel`; if legacy `selectedGuild` arrives, set both guild fields.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update voice route patches**
|
||||||
|
|
||||||
|
In `src/routes/voiceRoutes.ts`, connect patch becomes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selectedVoiceGuild: guildId,
|
||||||
|
selectedVoiceChannel: channelId,
|
||||||
|
```
|
||||||
|
|
||||||
|
Disconnect clears only:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selectedVoiceGuild: "",
|
||||||
|
selectedVoiceChannel: "",
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not clear text guild/channel on voice disconnect.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run state tests**
|
||||||
|
|
||||||
|
Run: `pnpm exec vitest run tests/routes/uiStateRoutes.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 4: Update Static Dashboard Selection
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `public/index.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace state fields**
|
||||||
|
|
||||||
|
Change JS state fields:
|
||||||
|
|
||||||
|
```js
|
||||||
|
selectedVoiceGuild: '',
|
||||||
|
selectedVoiceChannel: '',
|
||||||
|
selectedTextGuild: '',
|
||||||
|
selectedTextChannel: '',
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove direct reliance on `selectedGuild` except migration when applying server state.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add separate DOM selectors**
|
||||||
|
|
||||||
|
In the UI markup, provide separate select elements for voice guild and text guild. Use IDs:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<select id="voiceGuildSelect"></select>
|
||||||
|
<select id="channelSelect"></select>
|
||||||
|
<select id="textGuildSelect"></select>
|
||||||
|
<select id="channelFilter"></select>
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the `el` map to use `voiceGuildSelect` and `textGuildSelect`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Split channel loading functions**
|
||||||
|
|
||||||
|
Replace `loadChannels(guildId)` with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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}`));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Split state application**
|
||||||
|
|
||||||
|
In `applyServerState`, compute:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const nextVoiceGuild = next.selectedVoiceGuild || next.selectedGuild || '';
|
||||||
|
const nextTextGuild = next.selectedTextGuild || next.selectedGuild || '';
|
||||||
|
const voiceGuildChanged = nextVoiceGuild !== state.selectedVoiceGuild;
|
||||||
|
const textGuildChanged = nextTextGuild !== state.selectedTextGuild;
|
||||||
|
```
|
||||||
|
|
||||||
|
Load voice channels only when voice guild changes; load text channels only when text guild changes. Backlog sync uses `state.selectedTextGuild`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Split event listeners**
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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.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)); });
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Manual UI verification**
|
||||||
|
|
||||||
|
Run: `pnpm run build`
|
||||||
|
|
||||||
|
Expected: PASS. Then start the app if credentials are available and verify selecting voice guild does not reset text guild/channel and selecting text guild does not reset voice guild/channel.
|
||||||
|
|
||||||
|
## Task 5: Final Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No planned edits unless verification fails.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run lint**
|
||||||
|
|
||||||
|
Run: `pnpm run lint`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run typecheck**
|
||||||
|
|
||||||
|
Run: `pnpm run typecheck`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests**
|
||||||
|
|
||||||
|
Run: `pnpm run test`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run build**
|
||||||
|
|
||||||
|
Run: `pnpm run build`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Inspect status**
|
||||||
|
|
||||||
|
Run: `git status --short`
|
||||||
|
|
||||||
|
Expected: only intended files changed.
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- Spec coverage: config split is Task 1; capture/backlog filtering is Task 2; backend UI state split is Task 3; dashboard split is Task 4; verification is Task 5.
|
||||||
|
- Placeholder scan: no TBD/TODO/fill-in steps remain.
|
||||||
|
- Type consistency: split fields use `selectedVoiceGuild`, `selectedVoiceChannel`, `selectedTextGuild`, `selectedTextChannel` consistently.
|
||||||
1427
docs/superpowers/plans/2026-05-16-media-echo-screenshare.md
Normal file
1427
docs/superpowers/plans/2026-05-16-media-echo-screenshare.md
Normal file
File diff suppressed because it is too large
Load Diff
673
docs/superpowers/plans/2026-05-16-session-full-recording.md
Normal file
673
docs/superpowers/plans/2026-05-16-session-full-recording.md
Normal 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.
|
||||||
|
```
|
||||||
110
docs/superpowers/specs/2026-05-15-media-music-phase-1-design.md
Normal file
110
docs/superpowers/specs/2026-05-15-media-music-phase-1-design.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Media Music Phase 1 Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a first media playback phase focused on play music: users can queue, play, skip, and stop audio sources from the dashboard while preserving the existing Discord voice recorder, browser microphone transmit, and moderation capture flows.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Phase 1 implements audio-only playback and queue control. Share screen/video streaming is intentionally reserved for phase 2, but the controller shape should leave room for a later `screen` mode using the already vendored `@dank074/discord-video-stream` APIs seen in `MythEclipse/StreamBot`.
|
||||||
|
|
||||||
|
## Recommended Architecture
|
||||||
|
|
||||||
|
Create a small media subsystem under `src/media/`:
|
||||||
|
|
||||||
|
- `mediaTypes.ts` defines `MediaMode`, `MediaQueueItem`, `MediaState`, and request/response types.
|
||||||
|
- `mediaQueue.ts` owns in-memory queue operations: add, current, next, remove current, clear, snapshot.
|
||||||
|
- `mediaResolver.ts` resolves initial supported sources. Phase 1 should support direct HTTP(S) URLs and local file paths. YouTube/search can be added later because it requires adding or wrapping yt-dlp behavior.
|
||||||
|
- `musicPlayer.ts` converts a media source to Ogg Opus using ffmpeg and feeds the existing `discordPlayer.playStream()`.
|
||||||
|
- `mediaController.ts` coordinates queue state, voice connection assumptions, play/skip/stop, and WebSocket broadcast state.
|
||||||
|
|
||||||
|
The existing `VoiceController` remains the owner of joining/leaving voice channels. Phase 1 does not create a second voice connection path. Music playback requires the bot to already be connected through the existing voice UI or `/api/connect`; otherwise the media route returns `409 VOICE_NOT_CONNECTED`.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. Browser submits a source to `/api/media/queue` with `{ source }`.
|
||||||
|
2. `mediaResolver` validates and resolves the source into `{ source, title, kind }`.
|
||||||
|
3. `mediaQueue` appends a `MediaQueueItem`.
|
||||||
|
4. If no item is playing, `mediaController` starts playback of the current queue item.
|
||||||
|
5. `musicPlayer` spawns ffmpeg and outputs Ogg Opus to `discordPlayer.playStream()`.
|
||||||
|
6. When playback finishes, the controller removes the completed item and starts the next item.
|
||||||
|
7. State changes broadcast over the existing moderation broadcaster as a JSON WebSocket event, or via a small media broadcaster wrapper if that keeps types cleaner.
|
||||||
|
|
||||||
|
## API Design
|
||||||
|
|
||||||
|
Add `src/routes/mediaRoutes.ts` mounted under `/api`:
|
||||||
|
|
||||||
|
- `GET /api/media/status` returns `{ playing, current, queue }`.
|
||||||
|
- `POST /api/media/queue` accepts `{ source: string }`, queues it, and returns the updated state.
|
||||||
|
- `POST /api/media/skip` skips current item and starts the next if present.
|
||||||
|
- `POST /api/media/stop` stops playback and clears the queue.
|
||||||
|
|
||||||
|
All routes should use `AppError` for boundary validation. Empty source returns `400 MISSING_MEDIA_SOURCE`. No voice connection returns `409 VOICE_NOT_CONNECTED`.
|
||||||
|
|
||||||
|
## Dashboard Design
|
||||||
|
|
||||||
|
Add a compact Media card to the existing voice tab for phase 1:
|
||||||
|
|
||||||
|
- Source input: URL or local path.
|
||||||
|
- Buttons: Queue/Play, Skip, Stop.
|
||||||
|
- Current item label and queue list.
|
||||||
|
|
||||||
|
Do not add a separate full media tab yet. The voice tab already owns voice channel selection and connection state, so colocating music controls there reduces user confusion.
|
||||||
|
|
||||||
|
## Playback Details
|
||||||
|
|
||||||
|
Use ffmpeg directly or the existing `src/audio/ffmpegProcess.ts` helper if it already fits. The target stream should be Ogg Opus because `DiscordPlayer.playStream()` currently expects `StreamType.OggOpus`.
|
||||||
|
|
||||||
|
Recommended ffmpeg output shape:
|
||||||
|
|
||||||
|
- Input: local file or HTTP(S) URL.
|
||||||
|
- Output format: `ogg`.
|
||||||
|
- Audio codec: `libopus`.
|
||||||
|
- Sample rate: `48000`.
|
||||||
|
- Channels: `2`.
|
||||||
|
|
||||||
|
The controller owns an `AbortController` or child process handle so skip/stop can terminate ffmpeg. Stop must also call `discordPlayer.stop()` so the audio player releases the current resource.
|
||||||
|
|
||||||
|
## Concurrency Rules
|
||||||
|
|
||||||
|
- Only one media item plays at a time.
|
||||||
|
- Browser microphone transmit and music playback both use `discordPlayer`; phase 1 should disable music start while `isStreaming` is true, or stop browser transmit before playback. Prefer returning `409 BROWSER_STREAM_ACTIVE` to avoid surprising the user.
|
||||||
|
- Voice recording can continue while music plays because recording uses the receiver pipeline and music uses the player pipeline.
|
||||||
|
- Skip is serialized: concurrent skip calls should return the same resulting state or reject with `409 MEDIA_SKIP_IN_PROGRESS`.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Unsupported source format: `400 UNSUPPORTED_MEDIA_SOURCE`.
|
||||||
|
- ffmpeg spawn failure: current item becomes failed, playback advances to the next queued item if present.
|
||||||
|
- ffmpeg runtime failure: log stderr summary, mark item failed, advance queue.
|
||||||
|
- Stop is idempotent: stopping while idle returns current idle state.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Unit tests should cover:
|
||||||
|
|
||||||
|
- Queue add/next/remove/clear behavior.
|
||||||
|
- Resolver accepts HTTP(S) URLs and existing local paths, rejects empty/unsupported input.
|
||||||
|
- Controller rejects playback when voice is not connected.
|
||||||
|
- Controller starts next item after completion.
|
||||||
|
- Skip aborts current playback and advances queue.
|
||||||
|
- Routes validate payloads and call controller methods.
|
||||||
|
|
||||||
|
Manual verification should cover:
|
||||||
|
|
||||||
|
- Connect to a voice channel, queue a short audio URL or local file, hear playback in Discord.
|
||||||
|
- Queue two items, confirm automatic advance.
|
||||||
|
- Skip moves to the next item.
|
||||||
|
- Stop clears playback and queue.
|
||||||
|
- Existing voice recording and text moderation still work after media playback.
|
||||||
|
|
||||||
|
## Phase 2 Compatibility
|
||||||
|
|
||||||
|
Phase 2 can add `MediaMode = "screen"` and a `screenSharePlayer.ts` using StreamBot's pattern:
|
||||||
|
|
||||||
|
- `new Streamer(client)`
|
||||||
|
- `streamer.joinVoice(guildId, channelId)` only if phase 2 decides to own its own connection path
|
||||||
|
- `prepareStream(source, videoOptions, signal)`
|
||||||
|
- `playStream(output, streamer, { type: "go-live" }, signal)`
|
||||||
|
|
||||||
|
Phase 1 should not instantiate `Streamer`; it should only reserve type and controller seams so adding screen share later does not rewrite queue/status APIs.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
|
|||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
8
frontend/src/api/auth.ts
Normal 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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
28
frontend/src/api/media.ts
Normal 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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
3
frontend/src/api/messages.ts
Normal file
3
frontend/src/api/messages.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { listMessages, listReview, reanalyzeMessage } from "./client";
|
||||||
|
|
||||||
|
export { listMessages, listReview, reanalyzeMessage };
|
||||||
13
frontend/src/api/uiState.ts
Normal file
13
frontend/src/api/uiState.ts
Normal 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
29
frontend/src/api/voice.ts
Normal 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' });
|
||||||
|
}
|
||||||
62
frontend/src/components/layout/AuthOverlay.tsx
Normal file
62
frontend/src/components/layout/AuthOverlay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
frontend/src/components/layout/DashboardLayout.tsx
Normal file
28
frontend/src/components/layout/DashboardLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/components/layout/Header.tsx
Normal file
40
frontend/src/components/layout/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/components/layout/Sidebar.tsx
Normal file
48
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
frontend/src/components/media/MediaPanel.tsx
Normal file
50
frontend/src/components/media/MediaPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/components/media/MediaQueue.tsx
Normal file
46
frontend/src/components/media/MediaQueue.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
frontend/src/components/media/MusicPlayer.tsx
Normal file
84
frontend/src/components/media/MusicPlayer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
frontend/src/components/media/ScreenShare.tsx
Normal file
45
frontend/src/components/media/ScreenShare.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
frontend/src/components/messages/ImageGrid.tsx
Normal file
43
frontend/src/components/messages/ImageGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
66
frontend/src/components/messages/MessagesPanel.tsx
Normal file
66
frontend/src/components/messages/MessagesPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
30
frontend/src/components/ui/badge.tsx
Normal file
30
frontend/src/components/ui/badge.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/components/ui/button.tsx
Normal file
48
frontend/src/components/ui/button.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/components/ui/card.tsx
Normal file
26
frontend/src/components/ui/card.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
15
frontend/src/components/ui/input.tsx
Normal file
15
frontend/src/components/ui/input.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/components/ui/scroll-area.tsx
Normal file
30
frontend/src/components/ui/scroll-area.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
frontend/src/components/ui/select.tsx
Normal file
31
frontend/src/components/ui/select.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/components/ui/tabs.tsx
Normal file
35
frontend/src/components/ui/tabs.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/components/voice/ActiveSpeakers.tsx
Normal file
35
frontend/src/components/voice/ActiveSpeakers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
frontend/src/components/voice/AudioVisualizer.tsx
Normal file
18
frontend/src/components/voice/AudioVisualizer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
frontend/src/components/voice/VoiceControl.tsx
Normal file
83
frontend/src/components/voice/VoiceControl.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
frontend/src/components/voice/VoicePanel.tsx
Normal file
43
frontend/src/components/voice/VoicePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
frontend/src/hooks/useDashboardSocket.ts
Normal file
96
frontend/src/hooks/useDashboardSocket.ts
Normal 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 };
|
||||||
|
}
|
||||||
97
frontend/src/hooks/useMediaControl.ts
Normal file
97
frontend/src/hooks/useMediaControl.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
58
frontend/src/hooks/useMessages.ts
Normal file
58
frontend/src/hooks/useMessages.ts
Normal 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 };
|
||||||
|
}
|
||||||
35
frontend/src/hooks/useUIState.ts
Normal file
35
frontend/src/hooks/useUIState.ts
Normal 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 };
|
||||||
|
}
|
||||||
104
frontend/src/hooks/useVoiceControl.ts
Normal file
104
frontend/src/hooks/useVoiceControl.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
7
frontend/src/types/browser.ts
Normal file
7
frontend/src/types/browser.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export {};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
webkitAudioContext?: typeof AudioContext;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/src/types/media.ts
Normal file
17
frontend/src/types/media.ts
Normal 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[];
|
||||||
|
}
|
||||||
29
frontend/src/types/messages.ts
Normal file
29
frontend/src/types/messages.ts
Normal 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
12
frontend/src/types/ui.ts
Normal 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;
|
||||||
|
}
|
||||||
27
frontend/src/types/voice.ts
Normal file
27
frontend/src/types/voice.ts
Normal 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;
|
||||||
|
}
|
||||||
27
frontend/tailwind.config.js
Normal file
27
frontend/tailwind.config.js
Normal 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: [],
|
||||||
|
};
|
||||||
17
package.json
17
package.json
@@ -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
1886
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -1,158 +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>Channel / Thread</span><select id="channelFilter"><option value="">Select channel</option></select></div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div id="errorBox" class="error"></div>
|
|
||||||
|
|
||||||
<section id="voice" class="tab-content active">
|
|
||||||
<div class="voice-layout">
|
|
||||||
<div class="content-card">
|
|
||||||
<div class="card-title"><h2>Voice Control</h2><span class="mini">bridge</span></div>
|
|
||||||
<div class="field-group"><label for="guildSelect">Guild</label><select id="guildSelect"><option value="">Select guild</option></select></div>
|
|
||||||
<div class="field-group"><label for="channelSelect">Voice Channel</label><select id="channelSelect"><option value="">Select voice channel</option></select></div>
|
|
||||||
<div class="button-row"><button id="joinVoiceBtn" class="btn btn-success">Join</button><button id="disconnectVoiceBtn" class="btn btn-danger">Disconnect</button></div>
|
|
||||||
<div class="voice-status" id="voiceStatusNote">Idle</div>
|
|
||||||
</div>
|
|
||||||
<div class="content-card">
|
|
||||||
<div class="card-title"><h2>Live Audio</h2><span class="mini" id="listenStatus">Speaker Off</span></div>
|
|
||||||
<div style="display:grid;gap:12px;grid-template-columns:1fr 1fr;margin-bottom:14px"><button id="toggleBtn" class="btn btn-primary">Start Transmitting</button><button id="listenBtn" class="btn btn-success">Join Listen Channel</button></div>
|
|
||||||
<div class="visualizer" id="visualizer"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="content-card" style="margin-top:18px"><div class="card-title"><h2>Participants</h2><span class="mini">speaking now</span></div><div id="userList" class="participants"></div></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="text" class="tab-content"><div 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,
|
|
||||||
selectedGuild: '',
|
|
||||||
selectedVoiceChannel: '',
|
|
||||||
selectedTextChannel: '',
|
|
||||||
activeTab: 'voice',
|
|
||||||
text: [],
|
|
||||||
isStreaming: false,
|
|
||||||
isListening: false,
|
|
||||||
localStreaming: false,
|
|
||||||
localListening: false,
|
|
||||||
audioContextTransmit: null,
|
|
||||||
audioContextListen: null,
|
|
||||||
processor: null,
|
|
||||||
userTimelines: new Map(),
|
|
||||||
applyingServerState: false,
|
|
||||||
};
|
|
||||||
const SAMPLE_RATE = 24000;
|
|
||||||
const CHANNELS = 1;
|
|
||||||
const el = {
|
|
||||||
wsDot: document.getElementById('wsDot'), wsStatusText: document.getElementById('wsStatusText'), activeTabLabel: document.getElementById('activeTabLabel'), errorBox: document.getElementById('errorBox'), guildSelect: document.getElementById('guildSelect'), channelSelect: document.getElementById('channelSelect'), channelFilter: document.getElementById('channelFilter'), joinVoiceBtn: document.getElementById('joinVoiceBtn'), disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'), voiceStatusText: document.getElementById('voiceStatusText'), voiceStatusNote: document.getElementById('voiceStatusNote'), toggleBtn: document.getElementById('toggleBtn'), listenBtn: document.getElementById('listenBtn'), listenStatus: document.getElementById('listenStatus'), visualizer: document.getElementById('visualizer'), userList: document.getElementById('userList'), textList: document.getElementById('textList'), reviewList: document.getElementById('reviewList')
|
|
||||||
};
|
|
||||||
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.guildSelect, guilds, 'Select guild'); if (state.selectedGuild) { el.guildSelect.value = state.selectedGuild; await loadChannels(state.selectedGuild); } }
|
|
||||||
async function loadChannels(guildId) { if (!guildId) return; const [voiceChannels, watchChannels] = await Promise.all([apiRequest(`/api/guilds/${guildId}/voice-channels`), apiRequest(`/api/guilds/${guildId}/channels`)]); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); renderOptions(el.channelFilter, watchChannels, 'Select channel'); if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; 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.guildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedGuild: 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(); } }
|
|
||||||
|
|
||||||
async function applyServerState(next) {
|
|
||||||
if (!next || state.applyingServerState) return;
|
|
||||||
state.applyingServerState = true;
|
|
||||||
const guildChanged = next.selectedGuild !== state.selectedGuild;
|
|
||||||
const textChanged = next.selectedTextChannel !== state.selectedTextChannel;
|
|
||||||
state.selectedGuild = next.selectedGuild || '';
|
|
||||||
state.selectedVoiceChannel = next.selectedVoiceChannel || '';
|
|
||||||
state.selectedTextChannel = next.selectedTextChannel || '';
|
|
||||||
state.activeTab = next.activeTab || 'voice';
|
|
||||||
state.isListening = !!next.isListening;
|
|
||||||
state.isStreaming = !!next.isStreaming;
|
|
||||||
el.guildSelect.value = state.selectedGuild;
|
|
||||||
if (guildChanged && state.selectedGuild) await loadChannels(state.selectedGuild);
|
|
||||||
el.channelSelect.value = state.selectedVoiceChannel;
|
|
||||||
el.channelFilter.value = state.selectedTextChannel;
|
|
||||||
applyActiveTab(state.activeTab);
|
|
||||||
if (textChanged || state.activeTab === 'text') {
|
|
||||||
if (state.selectedTextChannel && state.selectedGuild) {
|
|
||||||
await apiRequest('/api/backlog-sync', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ guildId: state.selectedGuild, channelId: state.selectedTextChannel }),
|
|
||||||
}).catch((error) => logger.warn('Backlog sync failed:', error.message));
|
|
||||||
}
|
|
||||||
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.guildSelect.addEventListener('change', () => postUIState({ selectedGuild: el.guildSelect.value, selectedVoiceChannel: '', 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.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); });
|
|
||||||
|
|
||||||
connectWebSocket();
|
|
||||||
apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).catch((error) => showError(error.message));
|
|
||||||
setInterval(() => { if (state.activeTab === 'text') fetchText().catch(() => {}); }, 7000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
34
scripts/install-yt-dlp.sh
Executable file
34
scripts/install-yt-dlp.sh
Executable 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
|
||||||
@@ -6,6 +6,9 @@ const configSchema = z
|
|||||||
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
|
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
|
||||||
VOICE_CHANNEL_ID: z.string().min(1).optional(),
|
VOICE_CHANNEL_ID: z.string().min(1).optional(),
|
||||||
GUILD_ID: z.string().min(1).optional(),
|
GUILD_ID: z.string().min(1).optional(),
|
||||||
|
TEXT_GUILD_ID: z.string().min(1).optional(),
|
||||||
|
TEXT_CHANNEL_ID: z.string().min(1).optional(),
|
||||||
|
VOICE_GUILD_ID: z.string().min(1).optional(),
|
||||||
VERBOSE: z
|
VERBOSE: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -73,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) {
|
||||||
@@ -98,11 +102,19 @@ const configSchema = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppConfig = z.infer<typeof configSchema>;
|
export type AppConfig = z.infer<typeof configSchema> & {
|
||||||
|
EFFECTIVE_TEXT_GUILD_ID?: string;
|
||||||
|
EFFECTIVE_VOICE_GUILD_ID?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
try {
|
try {
|
||||||
return configSchema.parse(env);
|
const parsed = configSchema.parse(env);
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID,
|
||||||
|
EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
const messages = error.issues
|
const messages = error.issues
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
237
src/media/mediaController.ts
Normal file
237
src/media/mediaController.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { AppError } from "../errors";
|
||||||
|
import { discordPlayer } from "../player";
|
||||||
|
import { MediaQueue } from "./mediaQueue";
|
||||||
|
import { resolveMediaSource } from "./mediaResolver";
|
||||||
|
import type {
|
||||||
|
MediaMode,
|
||||||
|
MediaState,
|
||||||
|
MusicPlayback,
|
||||||
|
MusicPlayer,
|
||||||
|
QueueMediaOptions,
|
||||||
|
ResolvedMediaSource,
|
||||||
|
ScreenShareController,
|
||||||
|
ScreenSharePlayback,
|
||||||
|
} from "./mediaTypes";
|
||||||
|
import { createMusicPlayer } from "./musicPlayer";
|
||||||
|
|
||||||
|
export interface MediaControllerDependencies {
|
||||||
|
isVoiceConnected?: () => boolean;
|
||||||
|
isBrowserStreaming?: () => boolean;
|
||||||
|
resolveMediaSource?: (source: string, mode?: MediaMode) => Promise<ResolvedMediaSource>;
|
||||||
|
musicPlayer?: MusicPlayer;
|
||||||
|
screenController?: ScreenShareController;
|
||||||
|
onStateChange?: (state: MediaState) => void;
|
||||||
|
initialMusicVolume?: number;
|
||||||
|
onMusicVolumeChange?: (volume: number) => void | Promise<void>;
|
||||||
|
setMusicVolume?: (volume: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MediaController {
|
||||||
|
private readonly queueStore = new MediaQueue();
|
||||||
|
private readonly musicPlayer: MusicPlayer;
|
||||||
|
private playback: MusicPlayback | null = null;
|
||||||
|
private playbackToken = 0;
|
||||||
|
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 = {}) {
|
||||||
|
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 {
|
||||||
|
const snapshot = this.queueStore.snapshot();
|
||||||
|
return {
|
||||||
|
playing:
|
||||||
|
this.activeMode === "screen" || snapshot.current?.status === "playing",
|
||||||
|
activeMode: this.activeMode ?? snapshot.current?.mode ?? null,
|
||||||
|
musicVolume: this.musicVolume,
|
||||||
|
...snapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMusicVolume(volume: number): Promise<MediaState> {
|
||||||
|
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 (
|
||||||
|
this.dependencies.resolveMediaSource ?? resolveMediaSource
|
||||||
|
)(source, mode);
|
||||||
|
|
||||||
|
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();
|
||||||
|
return this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async skip(): Promise<MediaState> {
|
||||||
|
if (this.skipInProgress) {
|
||||||
|
throw new AppError(
|
||||||
|
"Skip already in progress",
|
||||||
|
"MEDIA_SKIP_IN_PROGRESS",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.skipInProgress = true;
|
||||||
|
try {
|
||||||
|
this.playbackToken++;
|
||||||
|
this.playback?.stop();
|
||||||
|
this.playback = null;
|
||||||
|
this.queueStore.completeCurrent();
|
||||||
|
this.startNextIfIdle();
|
||||||
|
return this.emitState();
|
||||||
|
} finally {
|
||||||
|
this.skipInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<MediaState> {
|
||||||
|
this.playbackToken++;
|
||||||
|
this.playback?.stop();
|
||||||
|
this.playback = null;
|
||||||
|
this.screenPlayback?.stop();
|
||||||
|
this.screenPlayback = null;
|
||||||
|
this.activeMode = null;
|
||||||
|
this.queueStore.clear();
|
||||||
|
return this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCanStartMusic(): void {
|
||||||
|
const isVoiceConnected =
|
||||||
|
this.dependencies.isVoiceConnected ?? (() => discordPlayer.isConnected());
|
||||||
|
if (!isVoiceConnected()) {
|
||||||
|
throw new AppError(
|
||||||
|
"Connect to a voice channel before playing media",
|
||||||
|
"VOICE_NOT_CONNECTED",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dependencies.isBrowserStreaming?.()) {
|
||||||
|
throw new AppError(
|
||||||
|
"Stop browser microphone streaming before playing media",
|
||||||
|
"BROWSER_STREAM_ACTIVE",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if (this.playback) return;
|
||||||
|
const item = this.queueStore.startNext();
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const token = ++this.playbackToken;
|
||||||
|
try {
|
||||||
|
this.playback = this.musicPlayer.play(item);
|
||||||
|
} catch {
|
||||||
|
this.queueStore.failCurrent();
|
||||||
|
this.playback = null;
|
||||||
|
this.startNextIfIdle();
|
||||||
|
this.emitState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.playback.done.then(
|
||||||
|
() => this.finishCurrent(token, false),
|
||||||
|
() => this.finishCurrent(token, true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishCurrent(token: number, failed: boolean): void {
|
||||||
|
if (token !== this.playbackToken) return;
|
||||||
|
this.playback = null;
|
||||||
|
if (failed) {
|
||||||
|
this.queueStore.failCurrent();
|
||||||
|
} else {
|
||||||
|
this.queueStore.completeCurrent();
|
||||||
|
}
|
||||||
|
this.startNextIfIdle();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitState(): MediaState {
|
||||||
|
const state = this.getState();
|
||||||
|
this.dependencies.onStateChange?.(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));
|
||||||
|
}
|
||||||
64
src/media/mediaQueue.ts
Normal file
64
src/media/mediaQueue.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type {
|
||||||
|
MediaMode,
|
||||||
|
MediaQueueItem,
|
||||||
|
MediaState,
|
||||||
|
ResolvedMediaSource,
|
||||||
|
} from "./mediaTypes";
|
||||||
|
|
||||||
|
export class MediaQueue {
|
||||||
|
private current: MediaQueueItem | null = null;
|
||||||
|
private readonly items: MediaQueueItem[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly createId: () => string = () => crypto.randomUUID(),
|
||||||
|
private readonly now = () => Date.now(),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
add(
|
||||||
|
source: ResolvedMediaSource,
|
||||||
|
mode: MediaQueueItem["mode"] = "music",
|
||||||
|
requestedBy = "dashboard",
|
||||||
|
): MediaQueueItem {
|
||||||
|
const item: MediaQueueItem = {
|
||||||
|
id: this.createId(),
|
||||||
|
mode,
|
||||||
|
requestedBy,
|
||||||
|
addedAt: this.now(),
|
||||||
|
status: "queued",
|
||||||
|
...source,
|
||||||
|
};
|
||||||
|
this.items.push(item);
|
||||||
|
return { ...item };
|
||||||
|
}
|
||||||
|
|
||||||
|
startNext(): MediaQueueItem | null {
|
||||||
|
if (this.current) return { ...this.current };
|
||||||
|
const next = this.items.shift();
|
||||||
|
if (!next) return null;
|
||||||
|
this.current = { ...next, status: "playing" };
|
||||||
|
return { ...this.current };
|
||||||
|
}
|
||||||
|
|
||||||
|
completeCurrent(): void {
|
||||||
|
this.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
failCurrent(): MediaQueueItem | null {
|
||||||
|
if (!this.current) return null;
|
||||||
|
const failed = { ...this.current, status: "failed" as const };
|
||||||
|
this.current = null;
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.current = null;
|
||||||
|
this.items.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(): Pick<MediaState, "current" | "queue"> {
|
||||||
|
return {
|
||||||
|
current: this.current ? { ...this.current } : null,
|
||||||
|
queue: this.items.map((item) => ({ ...item })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/media/mediaResolver.ts
Normal file
123
src/media/mediaResolver.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { existsSync, statSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { AppError } from "../errors";
|
||||||
|
import type { ResolvedMediaSource, MediaMode } from "./mediaTypes";
|
||||||
|
import { createPlayDlResolver } from "./playDlResolver";
|
||||||
|
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,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeUrl(source: string): boolean {
|
||||||
|
return /^[a-z][a-z\d+.-]*:/i.test(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
80
src/media/mediaTypes.ts
Normal file
80
src/media/mediaTypes.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { Readable } from "node:stream";
|
||||||
|
import type { StreamType } from "@discordjs/voice";
|
||||||
|
|
||||||
|
export type MediaMode = "music" | "screen";
|
||||||
|
export type MediaSourceKind =
|
||||||
|
| "url"
|
||||||
|
| "local"
|
||||||
|
| "youtube"
|
||||||
|
| "spotify"
|
||||||
|
| "search";
|
||||||
|
export type MediaQueueItemStatus = "queued" | "playing" | "failed";
|
||||||
|
|
||||||
|
export interface ResolvedMediaSource {
|
||||||
|
source: string;
|
||||||
|
title: string;
|
||||||
|
kind: MediaSourceKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaQueueItem extends ResolvedMediaSource {
|
||||||
|
id: string;
|
||||||
|
mode: MediaMode;
|
||||||
|
requestedBy: string;
|
||||||
|
addedAt: number;
|
||||||
|
status: MediaQueueItemStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaState {
|
||||||
|
playing: boolean;
|
||||||
|
activeMode: MediaMode | null;
|
||||||
|
musicVolume: number;
|
||||||
|
current: MediaQueueItem | null;
|
||||||
|
queue: MediaQueueItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueMediaOptions {
|
||||||
|
mode?: MediaMode;
|
||||||
|
requestedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicPlayback {
|
||||||
|
done: Promise<void>;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicPlayer {
|
||||||
|
play(source: ResolvedMediaSource): MusicPlayback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScreenSharePlayback {
|
||||||
|
done: Promise<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;
|
||||||
|
}
|
||||||
97
src/media/musicPlayer.ts
Normal file
97
src/media/musicPlayer.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
|
import { spawn as nodeSpawn } from "node:child_process";
|
||||||
|
import { StreamType } from "@discordjs/voice";
|
||||||
|
import { discordPlayer } from "../player";
|
||||||
|
import type {
|
||||||
|
DiscordAudioPlayer,
|
||||||
|
MusicPlayback,
|
||||||
|
MusicPlayer,
|
||||||
|
ResolvedMediaSource,
|
||||||
|
} from "./mediaTypes";
|
||||||
|
|
||||||
|
export interface MusicPlayerDependencies {
|
||||||
|
spawn?: typeof nodeSpawn;
|
||||||
|
discordPlayer?: DiscordAudioPlayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMusicPlayer(
|
||||||
|
dependencies: MusicPlayerDependencies = {},
|
||||||
|
): MusicPlayer {
|
||||||
|
const spawn = dependencies.spawn ?? nodeSpawn;
|
||||||
|
const audioPlayer = dependencies.discordPlayer ?? discordPlayer;
|
||||||
|
|
||||||
|
return {
|
||||||
|
play(source: ResolvedMediaSource): MusicPlayback {
|
||||||
|
if (!audioPlayer.isConnected()) {
|
||||||
|
throw new Error("Discord audio player is not connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = spawn("ffmpeg", buildFfmpegArgs(source.source), {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
}) as unknown as ChildProcessWithoutNullStreams;
|
||||||
|
proc.stderr.resume();
|
||||||
|
|
||||||
|
audioPlayer.playStream(proc.stdout, "music", {
|
||||||
|
inputType: StreamType.Raw,
|
||||||
|
inlineVolume: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
let released = false;
|
||||||
|
const release = () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
audioPlayer.stop("music");
|
||||||
|
};
|
||||||
|
|
||||||
|
const done = new Promise<void>((resolve, reject) => {
|
||||||
|
proc.on("error", (error) => {
|
||||||
|
release();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
proc.stdout.on("error", (error) => {
|
||||||
|
release();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
proc.on("close", (code) => {
|
||||||
|
release();
|
||||||
|
if (code === 0 || stopped) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`ffmpeg exited with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
done,
|
||||||
|
stop() {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
proc.kill("SIGTERM");
|
||||||
|
release();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFfmpegArgs(source: string): string[] {
|
||||||
|
return [
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"warning",
|
||||||
|
"-i",
|
||||||
|
source,
|
||||||
|
"-vn",
|
||||||
|
"-acodec",
|
||||||
|
"pcm_s16le",
|
||||||
|
"-ar",
|
||||||
|
"48000",
|
||||||
|
"-ac",
|
||||||
|
"2",
|
||||||
|
"-f",
|
||||||
|
"s16le",
|
||||||
|
"pipe:1",
|
||||||
|
];
|
||||||
|
}
|
||||||
60
src/media/playDlResolver.ts
Normal file
60
src/media/playDlResolver.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
159
src/media/screenShareController.ts
Normal file
159
src/media/screenShareController.ts
Normal 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
95
src/media/ytdlp.ts
Normal 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()}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
|||||||
112
src/moderation/aiAnalysisWorker.ts
Normal file
112
src/moderation/aiAnalysisWorker.ts
Normal 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));
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -54,24 +54,25 @@ async function syncChannelMessages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function syncBacklogMessages(client: Client): Promise<void> {
|
export async function syncBacklogMessages(client: Client): Promise<void> {
|
||||||
if (!config.MONITOR_GUILD_ID) {
|
const textGuildId = config.EFFECTIVE_TEXT_GUILD_ID;
|
||||||
logger.warn("MONITOR_GUILD_ID not configured, skipping backlog sync");
|
if (!textGuildId) {
|
||||||
|
logger.warn("TEXT_GUILD_ID not configured, skipping backlog sync");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const guild = client.guilds.cache.get(config.MONITOR_GUILD_ID);
|
const guild = client.guilds.cache.get(textGuildId);
|
||||||
if (!guild) {
|
if (!guild) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ guildId: config.MONITOR_GUILD_ID },
|
{ guildId: textGuildId },
|
||||||
"Monitor guild not found, skipping backlog sync",
|
"Text guild not found, skipping backlog sync",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
if (config.TEXT_CHANNEL_ID) {
|
||||||
{ guildId: guild.id },
|
await syncSelectedChannelBacklog(client, guild.id, config.TEXT_CHANNEL_ID);
|
||||||
"Backlog sync ready (will sync on-demand per selected channel)",
|
return;
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncSelectedChannelBacklog(
|
export async function syncSelectedChannelBacklog(
|
||||||
@@ -96,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(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createChildLogger } from "../logger";
|
|||||||
import type {
|
import type {
|
||||||
AnalysisQueueStatus,
|
AnalysisQueueStatus,
|
||||||
AttachmentRecord,
|
AttachmentRecord,
|
||||||
|
MediaState,
|
||||||
MessageRecord,
|
MessageRecord,
|
||||||
ModerationWsEvent,
|
ModerationWsEvent,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -72,6 +73,9 @@ export function createBroadcaster() {
|
|||||||
analysisQueueStatus(data: AnalysisQueueStatus) {
|
analysisQueueStatus(data: AnalysisQueueStatus) {
|
||||||
sendJson(clients, { type: "analysis_queue_status", data });
|
sendJson(clients, { type: "analysis_queue_status", data });
|
||||||
},
|
},
|
||||||
|
mediaState(state: MediaState) {
|
||||||
|
sendJson(clients, { type: "media_state", state });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,32 @@ function getModerationBroadcaster(): ModerationBroadcaster | undefined {
|
|||||||
return (globalThis as ModerationGlobal).moderationBroadcaster;
|
return (globalThis as ModerationGlobal).moderationBroadcaster;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TextCaptureTarget {
|
||||||
|
guildId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageLocationInput {
|
||||||
|
guildId?: string | null;
|
||||||
|
channelId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldCaptureMessageLocation(
|
||||||
|
message: MessageLocationInput,
|
||||||
|
target: TextCaptureTarget,
|
||||||
|
): boolean {
|
||||||
|
if (!message.guildId || message.guildId !== target.guildId) return false;
|
||||||
|
if (target.channelId && message.channelId !== target.channelId) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextCaptureTarget(): TextCaptureTarget {
|
||||||
|
return {
|
||||||
|
guildId: config.EFFECTIVE_TEXT_GUILD_ID,
|
||||||
|
channelId: config.TEXT_CHANNEL_ID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function captureMessage(
|
export async function captureMessage(
|
||||||
message: Message,
|
message: Message,
|
||||||
type: "text" | "edited" | "deleted",
|
type: "text" | "edited" | "deleted",
|
||||||
@@ -97,20 +123,11 @@ 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 {
|
||||||
client.on("messageCreate", async (message) => {
|
client.on("messageCreate", async (message) => {
|
||||||
if (!message.guildId || message.guildId !== config.MONITOR_GUILD_ID) return;
|
if (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
|
||||||
if (message.author?.bot) return;
|
if (message.author?.bot) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -127,7 +144,7 @@ export function registerMessageCapture(client: Client): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.on("messageUpdate", async (_oldMessage, newMessage) => {
|
client.on("messageUpdate", async (_oldMessage, newMessage) => {
|
||||||
if (!newMessage.guildId || newMessage.guildId !== config.MONITOR_GUILD_ID)
|
if (!shouldCaptureMessageLocation(newMessage, getTextCaptureTarget()))
|
||||||
return;
|
return;
|
||||||
if (newMessage.author?.bot) return;
|
if (newMessage.author?.bot) return;
|
||||||
|
|
||||||
@@ -166,7 +183,7 @@ export function registerMessageCapture(client: Client): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.on("messageDelete", async (message) => {
|
client.on("messageDelete", async (message) => {
|
||||||
if (!message.guildId || message.guildId !== config.MONITOR_GUILD_ID) return;
|
if (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
|
||||||
if (!message.author) return;
|
if (!message.author) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -180,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(
|
||||||
{
|
{
|
||||||
@@ -192,6 +207,4 @@ export function registerMessageCapture(client: Client): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info("Message capture handlers registered");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -92,6 +92,33 @@ export interface AnalysisResult {
|
|||||||
analysis: string;
|
analysis: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MediaMode = "music" | "screen";
|
||||||
|
export type MediaSourceKind =
|
||||||
|
| "url"
|
||||||
|
| "local"
|
||||||
|
| "youtube"
|
||||||
|
| "spotify"
|
||||||
|
| "search";
|
||||||
|
export type MediaQueueItemStatus = "queued" | "playing" | "failed";
|
||||||
|
|
||||||
|
export interface MediaQueueItem {
|
||||||
|
id: string;
|
||||||
|
mode: MediaMode;
|
||||||
|
source: string;
|
||||||
|
title: string;
|
||||||
|
kind: MediaSourceKind;
|
||||||
|
requestedBy: string;
|
||||||
|
addedAt: number;
|
||||||
|
status: MediaQueueItemStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaState {
|
||||||
|
playing: boolean;
|
||||||
|
musicVolume: number;
|
||||||
|
current: MediaQueueItem | null;
|
||||||
|
queue: MediaQueueItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export type ModerationWsEvent =
|
export type ModerationWsEvent =
|
||||||
| { type: "ui_state"; state: unknown }
|
| { type: "ui_state"; state: unknown }
|
||||||
| { type: "user_state"; users: unknown[] }
|
| { type: "user_state"; users: unknown[] }
|
||||||
@@ -100,7 +127,8 @@ export type ModerationWsEvent =
|
|||||||
| { type: "message_deleted"; data: { id: string; deleted_at: number } }
|
| { type: "message_deleted"; data: { id: string; deleted_at: number } }
|
||||||
| { type: "message_analyzed"; data: MessageRecord }
|
| { type: "message_analyzed"; data: MessageRecord }
|
||||||
| { type: "attachment_created"; data: AttachmentRecord }
|
| { type: "attachment_created"; data: AttachmentRecord }
|
||||||
| { type: "analysis_queue_status"; data: AnalysisQueueStatus };
|
| { type: "analysis_queue_status"; data: AnalysisQueueStatus }
|
||||||
|
| { type: "media_state"; state: MediaState };
|
||||||
|
|
||||||
export interface AnalysisQueueStatus {
|
export interface AnalysisQueueStatus {
|
||||||
queuedConversations: number;
|
queuedConversations: number;
|
||||||
|
|||||||
105
src/player.ts
105
src/player.ts
@@ -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,26 +39,97 @@ export class DiscordPlayer {
|
|||||||
this.connection.subscribe(this.player);
|
this.connection.subscribe(this.player);
|
||||||
}
|
}
|
||||||
|
|
||||||
public playStream(stream: Readable) {
|
public getOwner(): DiscordPlayerOwner {
|
||||||
console.log("[player] Starting new audio stream...");
|
return this.owner;
|
||||||
|
|
||||||
const resource = createAudioResource(stream, {
|
|
||||||
inputType: StreamType.OggOpus,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.player.play(resource);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public pause() {
|
public isConnected(): boolean {
|
||||||
|
return this.connection !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public playStream(
|
||||||
|
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, {
|
||||||
|
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.connection?.subscribe(this.player);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
192
src/recorder/sessionRecording.ts
Normal file
192
src/recorder/sessionRecording.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user