Compare commits
32 Commits
119258c2b0
...
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 |
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
|
||||||
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
|
||||||
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.
|
||||||
|
```
|
||||||
@@ -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 audioContext = audioContextListenRef.current;
|
||||||
|
if (!isListening || !audioContext) return;
|
||||||
|
const float32Array = new Float32Array(int16Array.length);
|
||||||
|
for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768;
|
||||||
|
const audioBuffer = audioContext.createBuffer(CHANNELS, float32Array.length / CHANNELS, SAMPLE_RATE);
|
||||||
|
audioBuffer.getChannelData(0).set(float32Array);
|
||||||
|
const source = audioContext.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(audioContext.destination);
|
||||||
|
const currentTime = audioContext.currentTime;
|
||||||
|
let nextStart = userTimelinesRef.current.get(userIdHash) || 0;
|
||||||
|
if (nextStart < currentTime) nextStart = currentTime + 0.05;
|
||||||
|
source.start(nextStart);
|
||||||
|
userTimelinesRef.current.set(userIdHash, nextStart + audioBuffer.duration);
|
||||||
|
}, [isListening]);
|
||||||
|
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ws = connectDashboardSocket((event: DashboardEvent) => {
|
const stopStreamingLocal = useCallback(() => {
|
||||||
switch (event.type) {
|
setIsStreaming(false);
|
||||||
case "message_created":
|
if (processorRef.current) {
|
||||||
setMessages((prev) => mergeMessages(prev, [event.data]));
|
processorRef.current.disconnect();
|
||||||
break;
|
processorRef.current = null;
|
||||||
case "message_analyzed":
|
|
||||||
setMessages((prev) => mergeMessages(prev, [event.data]));
|
|
||||||
break;
|
|
||||||
case "message_updated":
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) => (m.id === event.data.id ? { ...m, ...event.data } : m)),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "message_deleted":
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === event.data.id ? { ...m, type: "deleted" as const } : m,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
});
|
if (audioContextTransmitRef.current) {
|
||||||
|
audioContextTransmitRef.current.close();
|
||||||
wsRef.current = ws;
|
audioContextTransmitRef.current = null;
|
||||||
|
}
|
||||||
ws.addEventListener("open", () => setWsStatus("connected"));
|
if (streamRef.current) {
|
||||||
ws.addEventListener("close", () => setWsStatus("disconnected"));
|
for (const track of streamRef.current.getTracks()) track.stop();
|
||||||
ws.addEventListener("error", () => setWsStatus("error"));
|
streamRef.current = null;
|
||||||
|
}
|
||||||
return () => {
|
setLevels(Array.from({ length: 32 }, () => 0.04));
|
||||||
cancelled = true;
|
|
||||||
ws.close();
|
|
||||||
wsRef.current = null;
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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 });
|
||||||
} catch (err) {
|
streamRef.current = stream;
|
||||||
console.error("Reanalyze failed:", err);
|
setIsStreaming(true);
|
||||||
// Revert optimistic update on failure
|
|
||||||
setMessages((prev) =>
|
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||||
prev.map((m) =>
|
const audioContext = new AudioContextCtor({ sampleRate: SAMPLE_RATE });
|
||||||
m.id === id ? { ...m, ai_status: "error" as const, ai_error: "Reanalyze failed" } : m,
|
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) {
|
||||||
|
console.error("Microphone access failed:", err);
|
||||||
|
setIsStreaming(false);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [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}
|
||||||
</div>
|
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||||
|
>
|
||||||
<div className="main">
|
<div className="md:hidden">
|
||||||
<div className="header">
|
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
||||||
<h1>Discord Moderation Dashboard</h1>
|
<div className="mb-4 grid grid-cols-4 gap-2 rounded-2xl bg-muted p-1">
|
||||||
<span className="ws-status" data-status={wsStatus}>
|
{tabs.map((tab) => (
|
||||||
{wsStatus}
|
<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 })}>
|
||||||
</span>
|
{tab}
|
||||||
</div>
|
</button>
|
||||||
|
))}
|
||||||
<div className="content">
|
|
||||||
<MessageFeed messages={messages} onReanalyze={handleReanalyze} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ReviewPanel messages={messages} onReanalyze={handleReanalyze} />
|
|
||||||
</div>
|
</div>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
||||||
|
<TabsContent value="voice">
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||||
|
) : (
|
||||||
|
<VoicePanel
|
||||||
|
guilds={voice.guilds}
|
||||||
|
channels={voice.voiceChannels}
|
||||||
|
selectedGuild={selectedVoiceGuild}
|
||||||
|
selectedChannel={selectedVoiceChannel}
|
||||||
|
status={voice.voiceStatus}
|
||||||
|
loading={voice.loading}
|
||||||
|
activeSpeakers={activeSpeakers}
|
||||||
|
levels={levels}
|
||||||
|
isListening={isListening}
|
||||||
|
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">
|
||||||
|
<div className="flex gap-3">
|
||||||
<img
|
<img
|
||||||
src={message.avatar_url ?? "/default-avatar.png"}
|
src={message.avatar_url ?? "https://cdn.discordapp.com/embed/avatars/0.png"}
|
||||||
alt={message.username}
|
alt=""
|
||||||
className="message-card-avatar"
|
className="h-10 w-10 rounded-full object-cover"
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
/>
|
/>
|
||||||
<div className="message-card-body">
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
<div className="message-card-meta">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="message-card-username">{message.username}</span>
|
<span className="font-medium">{message.username || message.user_id}</span>
|
||||||
<span className="message-card-time">
|
<span className="text-xs text-muted-foreground">{new Date(message.created_at).toLocaleString()}</span>
|
||||||
{new Date(message.created_at).toLocaleString()}
|
{message.edited_at ? <Badge variant="outline">edited</Badge> : null}
|
||||||
</span>
|
{message.deleted_at ? <Badge variant="destructive">deleted</Badge> : null}
|
||||||
{message.type === "edited" && (
|
<Badge variant={aiVariant(aiStatus)}>{aiStatus}</Badge>
|
||||||
<span className="badge badge-edited">edited</span>
|
|
||||||
)}
|
|
||||||
{message.type === "deleted" && (
|
|
||||||
<span className="badge badge-deleted">deleted</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="badge badge-ai"
|
|
||||||
style={{ backgroundColor: statusColor }}
|
|
||||||
title={`AI: ${aiStatus}`}
|
|
||||||
>
|
|
||||||
{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>
|
||||||
|
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
|
||||||
|
{displayContent || "(empty message)"}
|
||||||
|
</p>
|
||||||
|
{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}
|
||||||
|
<Button size="sm" variant="outline" onClick={() => onReanalyze(message.id)} disabled={aiStatus === "pending"}>
|
||||||
|
<RotateCw className="h-3.5 w-3.5" />
|
||||||
|
Re-analyze
|
||||||
|
</Button>
|
||||||
</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: [],
|
||||||
|
};
|
||||||
16
package.json
16
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,22 +19,29 @@
|
|||||||
"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": "workspace:*",
|
"@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",
|
||||||
|
"@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",
|
||||||
@@ -44,6 +52,7 @@
|
|||||||
"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"
|
||||||
@@ -56,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"
|
||||||
}
|
}
|
||||||
|
|||||||
1876
pnpm-lock.yaml
generated
1876
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
|||||||
packages:
|
packages:
|
||||||
- .
|
- .
|
||||||
- vendor/discord.js-selfbot-v13
|
- vendor/discord.js-selfbot-v13
|
||||||
- vendor/Discord-video-stream
|
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@discordjs/opus'
|
- '@discordjs/opus'
|
||||||
|
|||||||
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,184 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Discord Moderation Watcher</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="/dashboard.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="shell">
|
|
||||||
<section class="hero">
|
|
||||||
<div class="brand-card">
|
|
||||||
<div class="eyebrow"><span class="pulse"></span> Discord moderation watcher</div>
|
|
||||||
<h1>Voice. Text. One Watch Floor.</h1>
|
|
||||||
<p class="subtitle">Static shared-state dashboard with legacy working voice bridge and captured Discord messages.</p>
|
|
||||||
</div>
|
|
||||||
<div class="status-card">
|
|
||||||
<div class="status-row"><span class="status-label">WebSocket</span><span class="status-value"><span id="wsDot" class="dot"></span><span id="wsStatusText">Connecting</span></span></div>
|
|
||||||
<div class="status-row"><span class="status-label">Voice Link</span><span id="voiceStatusText" class="status-value">Not connected</span></div>
|
|
||||||
<div class="status-row"><span class="status-label">Active Tab</span><span id="activeTabLabel" class="status-value">Voice</span></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<nav class="tab-panel">
|
|
||||||
<div class="tabs"><button class="tab-btn active" data-tab="voice">Voice</button><button class="tab-btn" data-tab="text">Text</button></div>
|
|
||||||
<div class="filter-row"><span>Text Guild</span><select id="textGuildSelect"><option value="">Select guild</option></select><span>Channel / Thread</span><select id="channelFilter"><option value="">Select channel</option></select></div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div id="errorBox" class="error"></div>
|
|
||||||
|
|
||||||
<section id="voice" class="tab-content active">
|
|
||||||
<div class="voice-layout">
|
|
||||||
<div class="content-card">
|
|
||||||
<div class="card-title"><h2>Voice Control</h2><span class="mini">bridge</span></div>
|
|
||||||
<div class="field-group"><label for="voiceGuildSelect">Voice Guild</label><select id="voiceGuildSelect"><option value="">Select guild</option></select></div>
|
|
||||||
<div class="field-group"><label for="channelSelect">Voice Channel</label><select id="channelSelect"><option value="">Select voice channel</option></select></div>
|
|
||||||
<div class="button-row"><button id="joinVoiceBtn" class="btn btn-success">Join</button><button id="disconnectVoiceBtn" class="btn btn-danger">Disconnect</button></div>
|
|
||||||
<div class="voice-status" id="voiceStatusNote">Idle</div>
|
|
||||||
</div>
|
|
||||||
<div class="content-card">
|
|
||||||
<div class="card-title"><h2>Live Audio</h2><span class="mini" id="listenStatus">Speaker Off</span></div>
|
|
||||||
<div style="display:grid;gap:12px;grid-template-columns:1fr 1fr;margin-bottom:14px"><button id="toggleBtn" class="btn btn-primary">Start Transmitting</button><button id="listenBtn" class="btn btn-success">Join Listen Channel</button></div>
|
|
||||||
<div class="visualizer" id="visualizer"></div>
|
|
||||||
</div>
|
|
||||||
<div class="content-card">
|
|
||||||
<div class="card-title"><h2>Media</h2><span class="mini" id="mediaStatus">Idle</span></div>
|
|
||||||
<div class="field-group"><label for="mediaSourceInput">Music URL, YouTube, Spotify track, search, or file path</label><input id="mediaSourceInput" type="text" placeholder="YouTube URL, Spotify track, or search terms"></div>
|
|
||||||
<div class="button-row"><button id="queueMediaBtn" class="btn btn-primary">Queue / Play</button><button id="skipMediaBtn" class="btn btn-success">Skip</button><button id="stopMediaBtn" class="btn btn-danger">Stop</button></div>
|
|
||||||
<div id="mediaQueueList" class="feed"><div class="empty">No media queued</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="content-card" style="margin-top:18px"><div class="card-title"><h2>Participants</h2><span class="mini">speaking now</span></div><div id="userList" class="participants"></div></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="text" class="tab-content"><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;height:100%"><div class="content-card"><div class="card-title"><h2>All Messages</h2><span class="mini">all captures</span></div><div id="textList" class="feed"><div class="empty">Select channel to view text captures</div></div></div><div class="content-card"><div class="card-title"><h2>Needs Review</h2><span class="mini">warn + flagged</span></div><div id="reviewList" class="feed"><div class="empty">No warned or flagged messages</div></div></div></div></section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const state = {
|
|
||||||
socket: null,
|
|
||||||
selectedVoiceGuild: '',
|
|
||||||
selectedVoiceChannel: '',
|
|
||||||
selectedTextGuild: '',
|
|
||||||
selectedTextChannel: '',
|
|
||||||
activeTab: 'voice',
|
|
||||||
text: [],
|
|
||||||
isStreaming: false,
|
|
||||||
isListening: false,
|
|
||||||
localStreaming: false,
|
|
||||||
localListening: false,
|
|
||||||
audioContextTransmit: null,
|
|
||||||
audioContextListen: null,
|
|
||||||
processor: null,
|
|
||||||
userTimelines: new Map(),
|
|
||||||
applyingServerState: false,
|
|
||||||
media: { playing: false, current: null, queue: [] },
|
|
||||||
};
|
|
||||||
const SAMPLE_RATE = 24000;
|
|
||||||
const CHANNELS = 1;
|
|
||||||
const el = {
|
|
||||||
wsDot: document.getElementById('wsDot'), wsStatusText: document.getElementById('wsStatusText'), activeTabLabel: document.getElementById('activeTabLabel'), errorBox: document.getElementById('errorBox'), voiceGuildSelect: document.getElementById('voiceGuildSelect'), textGuildSelect: document.getElementById('textGuildSelect'), channelSelect: document.getElementById('channelSelect'), channelFilter: document.getElementById('channelFilter'), joinVoiceBtn: document.getElementById('joinVoiceBtn'), disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'), voiceStatusText: document.getElementById('voiceStatusText'), voiceStatusNote: document.getElementById('voiceStatusNote'), toggleBtn: document.getElementById('toggleBtn'), listenBtn: document.getElementById('listenBtn'), listenStatus: document.getElementById('listenStatus'), visualizer: document.getElementById('visualizer'), userList: document.getElementById('userList'), textList: document.getElementById('textList'), reviewList: document.getElementById('reviewList'), mediaSourceInput: document.getElementById('mediaSourceInput'), mediaStatus: document.getElementById('mediaStatus'), queueMediaBtn: document.getElementById('queueMediaBtn'), skipMediaBtn: document.getElementById('skipMediaBtn'), stopMediaBtn: document.getElementById('stopMediaBtn'), mediaQueueList: document.getElementById('mediaQueueList')
|
|
||||||
};
|
|
||||||
for (let i = 0; i < 32; i++) { const bar = document.createElement('div'); bar.className = 'bar'; el.visualizer.appendChild(bar); }
|
|
||||||
const bars = [...document.querySelectorAll('.bar')];
|
|
||||||
|
|
||||||
async function apiRequest(url, options = {}) { const response = await fetch(url, { headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options }); if (!response.ok) { const error = await response.json().catch(() => ({ message: response.statusText })); throw new Error(error.message || response.statusText); } return response.json(); }
|
|
||||||
function showError(message) { el.errorBox.textContent = message; el.errorBox.style.display = 'block'; setTimeout(() => { el.errorBox.style.display = 'none'; }, 4500); }
|
|
||||||
function postUIState(patch) { return apiRequest('/api/ui-state', { method: 'POST', body: JSON.stringify(patch) }); }
|
|
||||||
function renderOptions(select, items, placeholder) { select.replaceChildren(); const first = document.createElement('option'); first.value = ''; first.textContent = placeholder; select.appendChild(first); for (const item of items) { const option = document.createElement('option'); option.value = item.id; option.textContent = item.name; select.appendChild(option); } }
|
|
||||||
function appendOptions(select, items) { const existing = new Set([...select.options].map((option) => option.value)); for (const item of items) { if (existing.has(item.id)) continue; const option = document.createElement('option'); option.value = item.id; option.textContent = item.name; select.appendChild(option); } }
|
|
||||||
function appendEmpty(parent, message) { const empty = document.createElement('div'); empty.className = 'empty'; empty.textContent = message; parent.appendChild(empty); }
|
|
||||||
function appendBadge(parent, label, className) { const badge = document.createElement('span'); badge.className = `badge ${className}`; badge.textContent = label; parent.appendChild(badge); }
|
|
||||||
function parseMetadata(value) { if (!value) return {}; try { return JSON.parse(value); } catch { return {}; } }
|
|
||||||
|
|
||||||
async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.voiceGuildSelect, guilds, 'Select guild'); renderOptions(el.textGuildSelect, guilds, 'Select guild'); if (state.selectedVoiceGuild) { el.voiceGuildSelect.value = state.selectedVoiceGuild; await loadVoiceChannels(state.selectedVoiceGuild); } if (state.selectedTextGuild) { el.textGuildSelect.value = state.selectedTextGuild; await loadTextChannels(state.selectedTextGuild); } }
|
|
||||||
async function loadVoiceChannels(guildId) { if (!guildId) return renderOptions(el.channelSelect, [], 'Select voice channel'); const voiceChannels = await apiRequest(`/api/guilds/${guildId}/voice-channels`); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; }
|
|
||||||
async function loadTextChannels(guildId) { if (!guildId) return renderOptions(el.channelFilter, [], 'Select channel'); const watchChannels = await apiRequest(`/api/guilds/${guildId}/channels`); renderOptions(el.channelFilter, watchChannels, 'Select channel'); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; apiRequest(`/api/guilds/${guildId}/threads`).then((threads) => { appendOptions(el.channelFilter, threads); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; }).catch((error) => showError(`Thread discovery failed: ${error.message}`)); }
|
|
||||||
async function refreshStatus() { try { const status = await apiRequest('/api/status'); el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected'; el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle'; } catch (error) { showError(error.message); } }
|
|
||||||
async function connectVoice() { const guildId = el.voiceGuildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: channelId }); const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) }); el.voiceStatusText.textContent = status.activeChannelName || 'Connected'; el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`; }
|
|
||||||
async function disconnectVoice() { await apiRequest('/api/disconnect', { method: 'POST' }); await refreshStatus(); }
|
|
||||||
|
|
||||||
function connectWebSocket() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; state.socket = new WebSocket(`${protocol}//${location.host}/ws`); state.socket.binaryType = 'arraybuffer'; state.socket.onopen = () => { el.wsDot.classList.add('on'); el.wsStatusText.textContent = 'Connected'; }; state.socket.onclose = () => { el.wsDot.classList.remove('on'); el.wsStatusText.textContent = 'Reconnecting'; setTimeout(connectWebSocket, 2500); }; state.socket.onerror = () => { el.wsDot.classList.remove('on'); el.wsDot.classList.add('warn'); el.wsStatusText.textContent = 'Socket error'; }; state.socket.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { handleIncomingPCM(event.data); return; } try { handleJsonEvent(event.data); } catch {} }; }
|
|
||||||
function handleJsonEvent(raw) { const message = JSON.parse(raw); if (message.type === 'ui_state') return applyServerState(message.state); if (message.type === 'user_state') return renderUsers(message.users || []); if (message.type === 'message_created') { state.text.unshift(message.data); renderText(); } if (message.type === 'message_updated') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, { edited_content: message.data.edited_content, edited_at: message.data.edited_at, type: 'edited' }); renderText(); } if (message.type === 'message_deleted') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, { deleted_at: message.data.deleted_at, type: 'deleted' }); renderText(); } if (message.type === 'attachment_uploaded') fetchText(); if (message.type === 'message_analyzed') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, message.data); renderText(); } if (message.type === 'media_state') { state.media = message.state; renderMedia(); } }
|
|
||||||
|
|
||||||
async function applyServerState(next) {
|
|
||||||
if (!next || state.applyingServerState) return;
|
|
||||||
state.applyingServerState = true;
|
|
||||||
const nextVoiceGuild = next.selectedVoiceGuild || next.selectedGuild || '';
|
|
||||||
const nextTextGuild = next.selectedTextGuild || next.selectedGuild || '';
|
|
||||||
const voiceGuildChanged = nextVoiceGuild !== state.selectedVoiceGuild;
|
|
||||||
const textGuildChanged = nextTextGuild !== state.selectedTextGuild;
|
|
||||||
const textChanged = next.selectedTextChannel !== state.selectedTextChannel;
|
|
||||||
state.selectedVoiceGuild = nextVoiceGuild;
|
|
||||||
state.selectedVoiceChannel = next.selectedVoiceChannel || '';
|
|
||||||
state.selectedTextGuild = nextTextGuild;
|
|
||||||
state.selectedTextChannel = next.selectedTextChannel || '';
|
|
||||||
state.activeTab = next.activeTab || 'voice';
|
|
||||||
state.isListening = !!next.isListening;
|
|
||||||
state.isStreaming = !!next.isStreaming;
|
|
||||||
el.voiceGuildSelect.value = state.selectedVoiceGuild;
|
|
||||||
el.textGuildSelect.value = state.selectedTextGuild;
|
|
||||||
if (voiceGuildChanged) await loadVoiceChannels(state.selectedVoiceGuild);
|
|
||||||
if (textGuildChanged) await loadTextChannels(state.selectedTextGuild);
|
|
||||||
el.channelSelect.value = state.selectedVoiceChannel;
|
|
||||||
el.channelFilter.value = state.selectedTextChannel;
|
|
||||||
applyActiveTab(state.activeTab);
|
|
||||||
if ((textChanged || textGuildChanged) && state.selectedTextChannel && state.selectedTextGuild) {
|
|
||||||
apiRequest('/api/backlog-sync', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ guildId: state.selectedTextGuild, channelId: state.selectedTextChannel }),
|
|
||||||
}).catch((error) => showError(`Backlog sync failed: ${error.message}`));
|
|
||||||
}
|
|
||||||
if (textChanged || textGuildChanged || state.activeTab === 'text') {
|
|
||||||
fetchText().catch((error) => showError(error.message));
|
|
||||||
}
|
|
||||||
await reconcileListenState();
|
|
||||||
await reconcileStreamingState();
|
|
||||||
state.applyingServerState = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyActiveTab(tab) { document.querySelectorAll('.tab-btn').forEach((item) => item.classList.toggle('active', item.dataset.tab === tab)); document.querySelectorAll('.tab-content').forEach((item) => item.classList.toggle('active', item.id === tab)); el.activeTabLabel.textContent = tab === 'text' ? 'Text' : 'Voice'; }
|
|
||||||
async function reconcileListenState() { if (state.isListening && !state.localListening) { try { await startListeningLocal(); } catch (error) { showError(`Speaker error: ${error.message}`); await postUIState({ isListening: false }); } } else if (!state.isListening && state.localListening) { stopListeningLocal(); } }
|
|
||||||
async function reconcileStreamingState() { if (state.isStreaming && !state.localStreaming) { try { await startStreamingLocal(); } catch (error) { showError(`Microphone error: ${error.message}`); await postUIState({ isStreaming: false }); } } else if (!state.isStreaming && state.localStreaming) { stopStreamingLocal(); } }
|
|
||||||
|
|
||||||
function renderUsers(users) { el.userList.replaceChildren(); if (users.length === 0) return appendEmpty(el.userList, 'No active speakers'); for (const user of users) { const row = document.createElement('div'); row.className = `user-item${user.speaking ? ' speaking' : ''}`; const img = document.createElement('img'); img.src = user.avatar || ''; img.alt = ''; const name = document.createElement('span'); name.textContent = user.username; row.append(img, name); el.userList.appendChild(row); } }
|
|
||||||
async function fetchText() { if (!state.selectedTextChannel) return renderText(); const result = await apiRequest(`/api/messages?channel=${encodeURIComponent(state.selectedTextChannel)}&type=text&limit=80`); state.text = result.data || []; renderText(); }
|
|
||||||
function renderText() { el.textList.replaceChildren(); el.reviewList.replaceChildren(); if (!state.selectedTextChannel) { appendEmpty(el.textList, 'Select channel to view text captures'); appendEmpty(el.reviewList, 'No warned or flagged messages'); return; } if (state.text.length === 0) { appendEmpty(el.textList, 'No text captures yet'); appendEmpty(el.reviewList, 'No warned or flagged messages'); return; } const reviewMessages = []; for (const msg of state.text) { const metadata = parseMetadata(msg.metadata); const card = document.createElement('article'); card.className = 'event-card'; const head = document.createElement('div'); head.className = 'event-head'; const author = document.createElement('div'); author.className = 'author'; const avatar = document.createElement('div'); avatar.className = 'avatar'; if (msg.avatar_url) { const img = document.createElement('img'); img.src = msg.avatar_url; img.alt = ''; avatar.appendChild(img); } const name = document.createElement('div'); name.className = 'name'; name.textContent = msg.username || msg.user_id; author.append(avatar, name); const time = document.createElement('div'); time.className = 'time'; time.textContent = new Date(msg.created_at).toLocaleString(); head.append(author, time); const text = document.createElement('div'); text.className = 'message-text'; text.textContent = msg.edited_content || msg.content || '(empty message)'; card.append(head, text); appendAIAnalysis(card, msg); appendMedia(card, metadata); const badges = document.createElement('div'); badges.className = 'badges'; if (metadata.reference?.messageId) appendBadge(badges, 'reply', ''); if (msg.thread_id) appendBadge(badges, metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : 'thread', ''); if (msg.edited_at) appendBadge(badges, 'edited', 'edit'); if (msg.deleted_at) appendBadge(badges, 'deleted', 'delete'); card.appendChild(badges); el.textList.appendChild(card); if (msg.ai_status === 'warn' || msg.ai_status === 'flagged') reviewMessages.push(card.cloneNode(true)); } if (reviewMessages.length === 0) { appendEmpty(el.reviewList, 'No warned or flagged messages'); } else { reviewMessages.forEach((card) => el.reviewList.appendChild(card)); } }
|
|
||||||
function appendAIAnalysis(card, msg) { const status = msg.ai_status || 'pending'; const wrap = document.createElement('div'); wrap.className = 'badges'; const badge = document.createElement('span'); badge.className = `badge ${status === 'flagged' ? 'delete' : status === 'clean' ? 'edit' : ''}`; badge.textContent = `AI: ${status}`; wrap.appendChild(badge); if (msg.ai_moderation_flags) { const flags = document.createElement('span'); flags.className = 'badge delete'; try { flags.textContent = JSON.parse(msg.ai_moderation_flags).join(', '); } catch { flags.textContent = msg.ai_moderation_flags; } wrap.appendChild(flags); } card.appendChild(wrap); if (msg.ai_analysis) { const analysis = document.createElement('div'); analysis.className = 'embed-description'; analysis.textContent = msg.ai_analysis; card.appendChild(analysis); } if (msg.ai_error) { const error = document.createElement('div'); error.className = 'embed-description'; error.textContent = `AI error: ${msg.ai_error}`; card.appendChild(error); } }
|
|
||||||
function appendMedia(card, metadata) { const stickers = document.createElement('div'); stickers.className = 'sticker-strip'; for (const sticker of metadata.stickers || []) { const img = document.createElement('img'); img.className = 'sticker-img'; img.src = sticker.url; img.alt = sticker.name; stickers.appendChild(img); } if (stickers.childElementCount) card.appendChild(stickers); const embeds = document.createElement('div'); embeds.className = 'feed'; for (const embed of metadata.embeds || []) { const item = document.createElement('div'); item.className = 'embed-card'; if (embed.title) { const title = document.createElement(embed.url ? 'a' : 'div'); title.className = 'embed-title'; title.textContent = embed.title; if (embed.url) { title.href = embed.url; title.target = '_blank'; title.rel = 'noreferrer'; } item.appendChild(title); } if (embed.description) { const desc = document.createElement('div'); desc.className = 'embed-description'; desc.textContent = embed.description; item.appendChild(desc); } if (embed.image || embed.thumbnail) { const img = document.createElement('img'); img.className = 'embed-image'; img.src = embed.image || embed.thumbnail; img.alt = embed.title || 'embed image'; item.appendChild(img); } embeds.appendChild(item); } if (embeds.childElementCount) card.appendChild(embeds); const attachments = document.createElement('div'); attachments.className = 'attachment-strip'; for (const attachment of metadata.attachments || []) { const link = document.createElement('a'); link.className = 'attachment-chip'; link.href = attachment.url; link.target = '_blank'; link.rel = 'noreferrer'; link.textContent = `${attachment.name} (${(attachment.size / 1024).toFixed(1)}KB)`; attachments.appendChild(link); } if (attachments.childElementCount) card.appendChild(attachments); }
|
|
||||||
|
|
||||||
function handleIncomingPCM(data) { if (!state.localListening || !state.audioContextListen) return; const headerView = new DataView(data, 0, 4); const userIdHash = headerView.getInt32(0, true); const audioData = data.slice(4); const int16Array = new Int16Array(audioData); const float32Array = new Float32Array(int16Array.length); for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768; const audioBuffer = state.audioContextListen.createBuffer(CHANNELS, float32Array.length / CHANNELS, SAMPLE_RATE); const nowBuffering = audioBuffer.getChannelData(0); for (let i = 0; i < audioBuffer.length; i++) nowBuffering[i] = float32Array[i]; const source = state.audioContextListen.createBufferSource(); source.buffer = audioBuffer; source.connect(state.audioContextListen.destination); const currentTime = state.audioContextListen.currentTime; let userNextStartTime = state.userTimelines.get(userIdHash) || 0; if (userNextStartTime < currentTime) userNextStartTime = currentTime + 0.05; source.start(userNextStartTime); userNextStartTime += audioBuffer.duration; state.userTimelines.set(userIdHash, userNextStartTime); }
|
|
||||||
async function startStreamingLocal() { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); state.localStreaming = true; el.toggleBtn.textContent = 'Stop Transmitting'; state.audioContextTransmit = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE }); const source = state.audioContextTransmit.createMediaStreamSource(stream); const analyser = state.audioContextTransmit.createAnalyser(); analyser.fftSize = 64; source.connect(analyser); const dataArray = new Uint8Array(analyser.frequencyBinCount); state.processor = state.audioContextTransmit.createScriptProcessor(4096, 1, 1); source.connect(state.processor); state.processor.connect(state.audioContextTransmit.destination); state.processor.onaudioprocess = (event) => { if (!state.localStreaming || state.socket.readyState !== WebSocket.OPEN) return; const inputData = event.inputBuffer.getChannelData(0); const pcmData = new Int16Array(inputData.length); for (let i = 0; i < inputData.length; i++) pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767; state.socket.send(pcmData.buffer); analyser.getByteFrequencyData(dataArray); bars.forEach((bar, index) => { const percent = (dataArray[index] / 255) * 100; bar.style.height = `${Math.max(2, percent)}%`; }); }; }
|
|
||||||
function stopStreamingLocal() { state.localStreaming = false; if (state.processor) state.processor.disconnect(); if (state.audioContextTransmit) state.audioContextTransmit.close(); state.processor = null; state.audioContextTransmit = null; el.toggleBtn.textContent = 'Start Transmitting'; bars.forEach((bar) => { bar.style.height = '2px'; }); }
|
|
||||||
async function startListeningLocal() { if (!state.audioContextListen) state.audioContextListen = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE }); await state.audioContextListen.resume(); state.localListening = true; el.listenBtn.textContent = 'Stop Listening'; el.listenStatus.textContent = 'Listening Live...'; }
|
|
||||||
function stopListeningLocal() { state.audioContextListen?.suspend(); state.userTimelines.clear(); state.localListening = false; el.listenBtn.textContent = 'Join Listen Channel'; el.listenStatus.textContent = 'Speaker Off'; }
|
|
||||||
function updateVisualizer(level) { bars.forEach((bar, index) => { const wave = Math.sin(index * 0.55 + Date.now() / 140) * 0.35 + 0.65; bar.style.height = `${Math.max(3, level * 190 * wave)}px`; }); }
|
|
||||||
|
|
||||||
document.querySelectorAll('.tab-btn').forEach((button) => { button.addEventListener('click', () => postUIState({ activeTab: button.dataset.tab }).catch((error) => showError(error.message))); });
|
|
||||||
el.voiceGuildSelect.addEventListener('change', () => postUIState({ selectedVoiceGuild: el.voiceGuildSelect.value, selectedVoiceChannel: '' }).catch((error) => showError(error.message)));
|
|
||||||
el.textGuildSelect.addEventListener('change', () => postUIState({ selectedTextGuild: el.textGuildSelect.value, selectedTextChannel: '' }).catch((error) => showError(error.message)));
|
|
||||||
el.channelSelect.addEventListener('change', () => postUIState({ selectedVoiceChannel: el.channelSelect.value }).catch((error) => showError(error.message)));
|
|
||||||
el.joinVoiceBtn.addEventListener('click', () => connectVoice().catch((error) => showError(error.message)));
|
|
||||||
el.disconnectVoiceBtn.addEventListener('click', () => disconnectVoice().catch((error) => showError(error.message)));
|
|
||||||
el.toggleBtn.addEventListener('click', () => postUIState({ isStreaming: !state.isStreaming }).catch((error) => showError(error.message)));
|
|
||||||
el.listenBtn.addEventListener('click', () => postUIState({ isListening: !state.isListening }).catch((error) => showError(error.message)));
|
|
||||||
el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.textGuildSelect.value) url.searchParams.set('guild', el.textGuildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); });
|
|
||||||
|
|
||||||
async function fetchMediaStatus() { state.media = await apiRequest('/api/media/status'); renderMedia(); }
|
|
||||||
async function queueMedia() { const source = el.mediaSourceInput.value.trim(); if (!source) return showError('Enter a music URL or local file path'); state.media = await apiRequest('/api/media/queue', { method: 'POST', body: JSON.stringify({ source }) }); el.mediaSourceInput.value = ''; renderMedia(); }
|
|
||||||
async function skipMedia() { state.media = await apiRequest('/api/media/skip', { method: 'POST' }); renderMedia(); }
|
|
||||||
async function stopMedia() { state.media = await apiRequest('/api/media/stop', { method: 'POST' }); renderMedia(); }
|
|
||||||
function renderMedia() { el.mediaQueueList.replaceChildren(); const current = state.media.current; el.mediaStatus.textContent = current ? `Playing ${current.title}` : 'Idle'; if (current) { const item = document.createElement('div'); item.className = 'event-card'; item.textContent = `Now: ${current.title}`; el.mediaQueueList.appendChild(item); } for (const queued of state.media.queue || []) { const item = document.createElement('div'); item.className = 'event-card'; item.textContent = queued.title; el.mediaQueueList.appendChild(item); } if (!current && (!state.media.queue || state.media.queue.length === 0)) appendEmpty(el.mediaQueueList, 'No media queued'); }
|
|
||||||
|
|
||||||
el.queueMediaBtn.addEventListener('click', () => queueMedia().catch((error) => showError(error.message)));
|
|
||||||
el.skipMediaBtn.addEventListener('click', () => skipMedia().catch((error) => showError(error.message)));
|
|
||||||
el.stopMediaBtn.addEventListener('click', () => stopMedia().catch((error) => showError(error.message)));
|
|
||||||
|
|
||||||
connectWebSocket();
|
|
||||||
apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).then(fetchMediaStatus).catch((error) => showError(error.message));
|
|
||||||
setInterval(() => { if (state.activeTab === 'text') fetchText().catch(() => {}); }, 7000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
34
scripts/install-yt-dlp.sh
Executable file
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
|
||||||
@@ -76,6 +76,7 @@ const configSchema = z
|
|||||||
POSTGRES_DB: z.string().optional(),
|
POSTGRES_DB: z.string().optional(),
|
||||||
POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2),
|
POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2),
|
||||||
POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10),
|
POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10),
|
||||||
|
ADMIN_PASSWORD: z.string().default("admin123"),
|
||||||
})
|
})
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
if (!value.AI_ANALYSIS_ENABLED) {
|
if (!value.AI_ANALYSIS_ENABLED) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -3,19 +3,27 @@ import { discordPlayer } from "../player";
|
|||||||
import { MediaQueue } from "./mediaQueue";
|
import { MediaQueue } from "./mediaQueue";
|
||||||
import { resolveMediaSource } from "./mediaResolver";
|
import { resolveMediaSource } from "./mediaResolver";
|
||||||
import type {
|
import type {
|
||||||
|
MediaMode,
|
||||||
MediaState,
|
MediaState,
|
||||||
MusicPlayback,
|
MusicPlayback,
|
||||||
MusicPlayer,
|
MusicPlayer,
|
||||||
|
QueueMediaOptions,
|
||||||
ResolvedMediaSource,
|
ResolvedMediaSource,
|
||||||
|
ScreenShareController,
|
||||||
|
ScreenSharePlayback,
|
||||||
} from "./mediaTypes";
|
} from "./mediaTypes";
|
||||||
import { createMusicPlayer } from "./musicPlayer";
|
import { createMusicPlayer } from "./musicPlayer";
|
||||||
|
|
||||||
export interface MediaControllerDependencies {
|
export interface MediaControllerDependencies {
|
||||||
isVoiceConnected?: () => boolean;
|
isVoiceConnected?: () => boolean;
|
||||||
isBrowserStreaming?: () => boolean;
|
isBrowserStreaming?: () => boolean;
|
||||||
resolveMediaSource?: (source: string) => Promise<ResolvedMediaSource>;
|
resolveMediaSource?: (source: string, mode?: MediaMode) => Promise<ResolvedMediaSource>;
|
||||||
musicPlayer?: MusicPlayer;
|
musicPlayer?: MusicPlayer;
|
||||||
|
screenController?: ScreenShareController;
|
||||||
onStateChange?: (state: MediaState) => void;
|
onStateChange?: (state: MediaState) => void;
|
||||||
|
initialMusicVolume?: number;
|
||||||
|
onMusicVolumeChange?: (volume: number) => void | Promise<void>;
|
||||||
|
setMusicVolume?: (volume: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MediaController {
|
export class MediaController {
|
||||||
@@ -24,25 +32,75 @@ export class MediaController {
|
|||||||
private playback: MusicPlayback | null = null;
|
private playback: MusicPlayback | null = null;
|
||||||
private playbackToken = 0;
|
private playbackToken = 0;
|
||||||
private skipInProgress = false;
|
private skipInProgress = false;
|
||||||
|
private screenPlayback: ScreenSharePlayback | null = null;
|
||||||
|
private activeMode: MediaMode | null = null;
|
||||||
|
private musicVolume: number;
|
||||||
|
private readonly setPlayerMusicVolume: (volume: number) => void;
|
||||||
|
|
||||||
constructor(private readonly dependencies: MediaControllerDependencies = {}) {
|
constructor(private readonly dependencies: MediaControllerDependencies = {}) {
|
||||||
this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer();
|
this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer();
|
||||||
|
this.setPlayerMusicVolume =
|
||||||
|
dependencies.setMusicVolume ??
|
||||||
|
((volume) => {
|
||||||
|
discordPlayer.setMusicVolume(volume);
|
||||||
|
});
|
||||||
|
this.musicVolume = normalizeVolume(dependencies.initialMusicVolume, 1);
|
||||||
|
this.setPlayerMusicVolume(this.musicVolume);
|
||||||
}
|
}
|
||||||
|
|
||||||
getState(): MediaState {
|
getState(): MediaState {
|
||||||
const snapshot = this.queueStore.snapshot();
|
const snapshot = this.queueStore.snapshot();
|
||||||
return {
|
return {
|
||||||
playing: snapshot.current?.status === "playing",
|
playing:
|
||||||
|
this.activeMode === "screen" || snapshot.current?.status === "playing",
|
||||||
|
activeMode: this.activeMode ?? snapshot.current?.mode ?? null,
|
||||||
|
musicVolume: this.musicVolume,
|
||||||
...snapshot,
|
...snapshot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async queue(source: string): Promise<MediaState> {
|
async setMusicVolume(volume: number): Promise<MediaState> {
|
||||||
this.assertCanStart();
|
const nextVolume = normalizeVolume(volume, this.musicVolume);
|
||||||
|
if (this.musicVolume === nextVolume) return this.emitState();
|
||||||
|
this.musicVolume = nextVolume;
|
||||||
|
this.setPlayerMusicVolume(nextVolume);
|
||||||
|
await this.dependencies.onMusicVolumeChange?.(nextVolume);
|
||||||
|
return this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async queue(
|
||||||
|
source: string,
|
||||||
|
options: QueueMediaOptions = {},
|
||||||
|
): Promise<MediaState> {
|
||||||
|
const mode = options.mode ?? "music";
|
||||||
|
|
||||||
const resolved = await (
|
const resolved = await (
|
||||||
this.dependencies.resolveMediaSource ?? resolveMediaSource
|
this.dependencies.resolveMediaSource ?? resolveMediaSource
|
||||||
)(source);
|
)(source, mode);
|
||||||
this.queueStore.add(resolved);
|
|
||||||
|
if (mode === "screen") {
|
||||||
|
// Stop current music if any
|
||||||
|
this.playbackToken++;
|
||||||
|
this.playback?.stop();
|
||||||
|
this.playback = null;
|
||||||
|
return this.startScreen(resolved.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mode === "music"
|
||||||
|
// If a screen share is active outside of this controller (browser-owned),
|
||||||
|
// reject to avoid stealing the shared player. If this controller started
|
||||||
|
// the screenPlayback, stop it and proceed.
|
||||||
|
if (this.screenPlayback || this.dependencies.screenController?.isActive()) {
|
||||||
|
if (this.dependencies.screenController?.isActive() && !this.screenPlayback) {
|
||||||
|
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
||||||
|
}
|
||||||
|
this.screenPlayback?.stop();
|
||||||
|
this.screenPlayback = null;
|
||||||
|
this.activeMode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assertCanStartMusic();
|
||||||
|
this.queueStore.add(resolved, mode, options.requestedBy);
|
||||||
this.startNextIfIdle();
|
this.startNextIfIdle();
|
||||||
return this.emitState();
|
return this.emitState();
|
||||||
}
|
}
|
||||||
@@ -73,11 +131,14 @@ export class MediaController {
|
|||||||
this.playbackToken++;
|
this.playbackToken++;
|
||||||
this.playback?.stop();
|
this.playback?.stop();
|
||||||
this.playback = null;
|
this.playback = null;
|
||||||
|
this.screenPlayback?.stop();
|
||||||
|
this.screenPlayback = null;
|
||||||
|
this.activeMode = null;
|
||||||
this.queueStore.clear();
|
this.queueStore.clear();
|
||||||
return this.emitState();
|
return this.emitState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertCanStart(): void {
|
private assertCanStartMusic(): void {
|
||||||
const isVoiceConnected =
|
const isVoiceConnected =
|
||||||
this.dependencies.isVoiceConnected ?? (() => discordPlayer.isConnected());
|
this.dependencies.isVoiceConnected ?? (() => discordPlayer.isConnected());
|
||||||
if (!isVoiceConnected()) {
|
if (!isVoiceConnected()) {
|
||||||
@@ -97,6 +158,38 @@ export class MediaController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async startScreen(source: string): Promise<MediaState> {
|
||||||
|
const screenController = this.dependencies.screenController;
|
||||||
|
if (!screenController) {
|
||||||
|
throw new AppError(
|
||||||
|
"Screen sharing is unavailable",
|
||||||
|
"SCREEN_UNAVAILABLE",
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeMode = "screen";
|
||||||
|
try {
|
||||||
|
this.screenPlayback = await screenController.start(source);
|
||||||
|
} catch (error) {
|
||||||
|
this.activeMode = null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.screenPlayback.done.then(
|
||||||
|
() => this.finishScreen(),
|
||||||
|
() => this.finishScreen(),
|
||||||
|
);
|
||||||
|
return this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishScreen(): void {
|
||||||
|
if (!this.screenPlayback || this.activeMode !== "screen") return;
|
||||||
|
this.screenPlayback = null;
|
||||||
|
this.activeMode = null;
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
private startNextIfIdle(): void {
|
private startNextIfIdle(): void {
|
||||||
if (this.playback) return;
|
if (this.playback) return;
|
||||||
const item = this.queueStore.startNext();
|
const item = this.queueStore.startNext();
|
||||||
@@ -137,3 +230,8 @@ export class MediaController {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeVolume(value: number | undefined, fallback: number): number {
|
||||||
|
if (!Number.isFinite(value)) return fallback;
|
||||||
|
return Math.max(0, Math.min(1, value as number));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
MediaMode,
|
||||||
MediaQueueItem,
|
MediaQueueItem,
|
||||||
MediaState,
|
MediaState,
|
||||||
ResolvedMediaSource,
|
ResolvedMediaSource,
|
||||||
@@ -13,10 +14,14 @@ export class MediaQueue {
|
|||||||
private readonly now = () => Date.now(),
|
private readonly now = () => Date.now(),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
add(source: ResolvedMediaSource, requestedBy = "dashboard"): MediaQueueItem {
|
add(
|
||||||
|
source: ResolvedMediaSource,
|
||||||
|
mode: MediaQueueItem["mode"] = "music",
|
||||||
|
requestedBy = "dashboard",
|
||||||
|
): MediaQueueItem {
|
||||||
const item: MediaQueueItem = {
|
const item: MediaQueueItem = {
|
||||||
id: this.createId(),
|
id: this.createId(),
|
||||||
mode: "music",
|
mode,
|
||||||
requestedBy,
|
requestedBy,
|
||||||
addedAt: this.now(),
|
addedAt: this.now(),
|
||||||
status: "queued",
|
status: "queued",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, statSync } from "node:fs";
|
import { existsSync, statSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { AppError } from "../errors";
|
import { AppError } from "../errors";
|
||||||
import type { ResolvedMediaSource } from "./mediaTypes";
|
import type { ResolvedMediaSource, MediaMode } from "./mediaTypes";
|
||||||
import { createPlayDlResolver } from "./playDlResolver";
|
import { createPlayDlResolver } from "./playDlResolver";
|
||||||
import { createYtDlp, type YtDlpClient } from "./ytdlp";
|
import { createYtDlp, type YtDlpClient } from "./ytdlp";
|
||||||
|
|
||||||
@@ -18,7 +18,10 @@ export function createMediaResolver(
|
|||||||
const ytdlp = dependencies.ytdlp ?? createYtDlp();
|
const ytdlp = dependencies.ytdlp ?? createYtDlp();
|
||||||
const playDlResolver = dependencies.playDlResolver ?? createPlayDlResolver();
|
const playDlResolver = dependencies.playDlResolver ?? createPlayDlResolver();
|
||||||
|
|
||||||
return async function resolve(input: string): Promise<ResolvedMediaSource> {
|
return async function resolve(
|
||||||
|
input: string,
|
||||||
|
mode: MediaMode = "music"
|
||||||
|
): Promise<ResolvedMediaSource> {
|
||||||
const source = input.trim();
|
const source = input.trim();
|
||||||
if (!source) {
|
if (!source) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
@@ -31,13 +34,17 @@ export function createMediaResolver(
|
|||||||
const url = parseUrl(source);
|
const url = parseUrl(source);
|
||||||
if (url && isYouTubeUrl(url)) {
|
if (url && isYouTubeUrl(url)) {
|
||||||
const metadata = await ytdlp.getMetadata(source);
|
const metadata = await ytdlp.getMetadata(source);
|
||||||
const directUrl = await ytdlp.getDirectAudioUrl(source);
|
const directUrl = mode === "screen"
|
||||||
|
? await ytdlp.getDirectVideoUrl(source)
|
||||||
|
: await ytdlp.getDirectAudioUrl(source);
|
||||||
return { source: directUrl, title: metadata.title, kind: "youtube" };
|
return { source: directUrl, title: metadata.title, kind: "youtube" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url && isSpotifyTrackUrl(url)) {
|
if (url && isSpotifyTrackUrl(url)) {
|
||||||
const result = await playDlResolver.resolveSpotifyTrack(source);
|
const result = await playDlResolver.resolveSpotifyTrack(source);
|
||||||
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
|
const directUrl = mode === "screen"
|
||||||
|
? await ytdlp.getDirectVideoUrl(result.url)
|
||||||
|
: await ytdlp.getDirectAudioUrl(result.url);
|
||||||
return { source: directUrl, title: result.title, kind: "spotify" };
|
return { source: directUrl, title: result.title, kind: "spotify" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +62,9 @@ export function createMediaResolver(
|
|||||||
|
|
||||||
if (!url && !looksLikeUrl(source)) {
|
if (!url && !looksLikeUrl(source)) {
|
||||||
const result = await playDlResolver.searchYouTube(source);
|
const result = await playDlResolver.searchYouTube(source);
|
||||||
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
|
const directUrl = mode === "screen"
|
||||||
|
? await ytdlp.getDirectVideoUrl(result.url)
|
||||||
|
: await ytdlp.getDirectAudioUrl(result.url);
|
||||||
return { source: directUrl, title: result.title, kind: "search" };
|
return { source: directUrl, title: result.title, kind: "search" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Readable } from "node:stream";
|
import type { Readable } from "node:stream";
|
||||||
|
import type { StreamType } from "@discordjs/voice";
|
||||||
|
|
||||||
export type MediaMode = "music" | "screen";
|
export type MediaMode = "music" | "screen";
|
||||||
export type MediaSourceKind =
|
export type MediaSourceKind =
|
||||||
@@ -25,10 +26,17 @@ export interface MediaQueueItem extends ResolvedMediaSource {
|
|||||||
|
|
||||||
export interface MediaState {
|
export interface MediaState {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
|
activeMode: MediaMode | null;
|
||||||
|
musicVolume: number;
|
||||||
current: MediaQueueItem | null;
|
current: MediaQueueItem | null;
|
||||||
queue: MediaQueueItem[];
|
queue: MediaQueueItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QueueMediaOptions {
|
||||||
|
mode?: MediaMode;
|
||||||
|
requestedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MusicPlayback {
|
export interface MusicPlayback {
|
||||||
done: Promise<void>;
|
done: Promise<void>;
|
||||||
stop(): void;
|
stop(): void;
|
||||||
@@ -38,8 +46,35 @@ export interface MusicPlayer {
|
|||||||
play(source: ResolvedMediaSource): MusicPlayback;
|
play(source: ResolvedMediaSource): MusicPlayback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscordAudioPlayer {
|
export interface ScreenSharePlayback {
|
||||||
isConnected(): boolean;
|
done: Promise<void>;
|
||||||
playStream(stream: Readable): void;
|
|
||||||
stop(): void;
|
stop(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScreenShareController {
|
||||||
|
isActive(): boolean;
|
||||||
|
start(source: string): Promise<ScreenSharePlayback>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen";
|
||||||
|
|
||||||
|
export interface DiscordPlayOptions {
|
||||||
|
inputType?: StreamType;
|
||||||
|
inlineVolume?: boolean;
|
||||||
|
volume?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordAudioPlayer {
|
||||||
|
getOwner(): DiscordPlayerOwner;
|
||||||
|
isConnected(): boolean;
|
||||||
|
playStream(
|
||||||
|
stream: Readable,
|
||||||
|
owner: DiscordPlayerOwner,
|
||||||
|
options?: DiscordPlayOptions,
|
||||||
|
): void;
|
||||||
|
pause(owner?: DiscordPlayerOwner): void;
|
||||||
|
unpause(owner?: DiscordPlayerOwner): boolean;
|
||||||
|
stop(owner?: DiscordPlayerOwner): void;
|
||||||
|
getMusicVolume(): number;
|
||||||
|
setMusicVolume(volume: number): void;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import { spawn as nodeSpawn } from "node:child_process";
|
import { spawn as nodeSpawn } from "node:child_process";
|
||||||
|
import { StreamType } from "@discordjs/voice";
|
||||||
import { discordPlayer } from "../player";
|
import { discordPlayer } from "../player";
|
||||||
import type {
|
import type {
|
||||||
DiscordAudioPlayer,
|
DiscordAudioPlayer,
|
||||||
@@ -30,13 +31,30 @@ export function createMusicPlayer(
|
|||||||
}) as unknown as ChildProcessWithoutNullStreams;
|
}) as unknown as ChildProcessWithoutNullStreams;
|
||||||
proc.stderr.resume();
|
proc.stderr.resume();
|
||||||
|
|
||||||
audioPlayer.playStream(proc.stdout);
|
audioPlayer.playStream(proc.stdout, "music", {
|
||||||
|
inputType: StreamType.Raw,
|
||||||
|
inlineVolume: true,
|
||||||
|
});
|
||||||
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
let released = false;
|
||||||
|
const release = () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
audioPlayer.stop("music");
|
||||||
|
};
|
||||||
|
|
||||||
const done = new Promise<void>((resolve, reject) => {
|
const done = new Promise<void>((resolve, reject) => {
|
||||||
proc.on("error", reject);
|
proc.on("error", (error) => {
|
||||||
proc.stdout.on("error", reject);
|
release();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
proc.stdout.on("error", (error) => {
|
||||||
|
release();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
proc.on("close", (code) => {
|
proc.on("close", (code) => {
|
||||||
|
release();
|
||||||
if (code === 0 || stopped) {
|
if (code === 0 || stopped) {
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
@@ -51,7 +69,7 @@ export function createMusicPlayer(
|
|||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
stopped = true;
|
stopped = true;
|
||||||
proc.kill("SIGTERM");
|
proc.kill("SIGTERM");
|
||||||
audioPlayer.stop();
|
release();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -67,13 +85,13 @@ export function buildFfmpegArgs(source: string): string[] {
|
|||||||
source,
|
source,
|
||||||
"-vn",
|
"-vn",
|
||||||
"-acodec",
|
"-acodec",
|
||||||
"libopus",
|
"pcm_s16le",
|
||||||
"-ar",
|
"-ar",
|
||||||
"48000",
|
"48000",
|
||||||
"-ac",
|
"-ac",
|
||||||
"2",
|
"2",
|
||||||
"-f",
|
"-f",
|
||||||
"ogg",
|
"s16le",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export interface YtDlpMetadata {
|
|||||||
export interface YtDlpClient {
|
export interface YtDlpClient {
|
||||||
getMetadata(url: string): Promise<YtDlpMetadata>;
|
getMetadata(url: string): Promise<YtDlpMetadata>;
|
||||||
getDirectAudioUrl(url: string): Promise<string>;
|
getDirectAudioUrl(url: string): Promise<string>;
|
||||||
|
getDirectVideoUrl(url: string): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YtDlpDependencies {
|
export interface YtDlpDependencies {
|
||||||
@@ -49,6 +50,19 @@ export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient {
|
|||||||
]);
|
]);
|
||||||
return value.trim().split("\n")[0] || url;
|
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();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
logger.info(
|
conversationKey,
|
||||||
{ conversationKey, count: messages.length },
|
Date.now() + ERROR_COOLDOWN_MS,
|
||||||
"Batch analysis complete",
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
|
||||||
lastError = error instanceof Error ? error.message : String(error);
|
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
{ conversationKey, error: lastError },
|
{ conversationKey, error: lastError },
|
||||||
"Batch analysis failed",
|
"Batch analysis failed",
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
conversationErrorCooldown.delete(conversationKey);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
conversationErrorCooldown.set(
|
||||||
|
conversationKey,
|
||||||
|
Date.now() + ERROR_COOLDOWN_MS,
|
||||||
|
);
|
||||||
|
logger.error(
|
||||||
|
{ conversationKey, error: lastError },
|
||||||
|
"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);
|
||||||
|
|||||||
@@ -73,11 +73,6 @@ export async function syncBacklogMessages(client: Client): Promise<void> {
|
|||||||
await syncSelectedChannelBacklog(client, guild.id, config.TEXT_CHANNEL_ID);
|
await syncSelectedChannelBacklog(client, guild.id, config.TEXT_CHANNEL_ID);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
|
||||||
{ guildId: guild.id },
|
|
||||||
"Backlog sync ready (will sync on-demand per selected channel)",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncSelectedChannelBacklog(
|
export async function syncSelectedChannelBacklog(
|
||||||
@@ -102,17 +97,8 @@ export async function syncSelectedChannelBacklog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cutoffTime = Date.now() - config.BACKLOG_SYNC_HOURS * 60 * 60 * 1000;
|
const cutoffTime = Date.now() - config.BACKLOG_SYNC_HOURS * 60 * 60 * 1000;
|
||||||
logger.info(
|
|
||||||
{ guildId, channelId, hours: config.BACKLOG_SYNC_HOURS },
|
|
||||||
"Starting backlog sync for selected channel",
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const count = await syncChannelMessages(channel, cutoffTime);
|
const count = await syncChannelMessages(channel, cutoffTime);
|
||||||
logger.info(
|
|
||||||
{ channelId, count },
|
|
||||||
"Backlog sync completed for selected channel",
|
|
||||||
);
|
|
||||||
return count;
|
return count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|||||||
@@ -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++) {
|
|
||||||
if (content[i] === "}") {
|
|
||||||
const candidate = content.substring(startIdx, i + 1);
|
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(candidate);
|
parsed = JSON.parse(candidate);
|
||||||
// Successfully parsed, break out
|
|
||||||
break;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Store error and continue scanning
|
// If full substring fails, try scanning backwards from the last }
|
||||||
lastError = error instanceof Error ? error : new Error(String(error));
|
let lastError: Error =
|
||||||
|
error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
|
for (let i = endIdx - 1; i > startIdx; i--) {
|
||||||
|
if (content[i] === "}") {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(content.substring(startIdx, i + 1));
|
||||||
|
break;
|
||||||
|
} catch (innerError) {
|
||||||
|
lastError =
|
||||||
|
innerError instanceof Error
|
||||||
|
? innerError
|
||||||
|
: new Error(String(innerError));
|
||||||
continue;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,15 +123,6 @@ export async function captureMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
messageId: message.id,
|
|
||||||
channelId: message.channelId,
|
|
||||||
attachmentCount: message.attachments.size,
|
|
||||||
},
|
|
||||||
"Message captured",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerMessageCapture(client: Client): void {
|
export function registerMessageCapture(client: Client): void {
|
||||||
@@ -206,8 +197,6 @@ export function registerMessageCapture(client: Client): void {
|
|||||||
deleted_at: deletedAt,
|
deleted_at: deletedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info({ messageId: message.id }, "Message deletion captured");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{
|
{
|
||||||
@@ -218,6 +207,4 @@ export function registerMessageCapture(client: Client): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info("Message capture handlers registered");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export interface MediaQueueItem {
|
|||||||
|
|
||||||
export interface MediaState {
|
export interface MediaState {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
|
musicVolume: number;
|
||||||
current: MediaQueueItem | null;
|
current: MediaQueueItem | null;
|
||||||
queue: MediaQueueItem[];
|
queue: MediaQueueItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,23 @@ import { Readable } from "node:stream";
|
|||||||
import {
|
import {
|
||||||
AudioPlayer,
|
AudioPlayer,
|
||||||
AudioPlayerStatus,
|
AudioPlayerStatus,
|
||||||
|
type AudioResource,
|
||||||
createAudioPlayer,
|
createAudioPlayer,
|
||||||
createAudioResource,
|
createAudioResource,
|
||||||
StreamType,
|
StreamType,
|
||||||
VoiceConnection,
|
VoiceConnection,
|
||||||
} from "@discordjs/voice";
|
} from "@discordjs/voice";
|
||||||
|
import type {
|
||||||
|
DiscordPlayOptions,
|
||||||
|
DiscordPlayerOwner,
|
||||||
|
} from "./media/mediaTypes";
|
||||||
|
|
||||||
export class DiscordPlayer {
|
export class DiscordPlayer {
|
||||||
private player: AudioPlayer;
|
private player: AudioPlayer;
|
||||||
private connection: VoiceConnection | null = null;
|
private connection: VoiceConnection | null = null;
|
||||||
|
private owner: DiscordPlayerOwner = "none";
|
||||||
|
private resource: AudioResource | null = null;
|
||||||
|
private musicVolume = 1;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.player = createAudioPlayer();
|
this.player = createAudioPlayer();
|
||||||
@@ -21,6 +29,8 @@ export class DiscordPlayer {
|
|||||||
|
|
||||||
this.player.on("error", (error) => {
|
this.player.on("error", (error) => {
|
||||||
console.error(`[player] Error: ${error.message}`);
|
console.error(`[player] Error: ${error.message}`);
|
||||||
|
this.owner = "none";
|
||||||
|
this.resource = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,30 +39,97 @@ export class DiscordPlayer {
|
|||||||
this.connection.subscribe(this.player);
|
this.connection.subscribe(this.player);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getOwner(): DiscordPlayerOwner {
|
||||||
|
return this.owner;
|
||||||
|
}
|
||||||
|
|
||||||
public isConnected(): boolean {
|
public isConnected(): boolean {
|
||||||
return this.connection !== null;
|
return this.connection !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public playStream(stream: Readable) {
|
public playStream(
|
||||||
console.log("[player] Starting new audio stream...");
|
stream: Readable,
|
||||||
|
owner: DiscordPlayerOwner,
|
||||||
|
options: DiscordPlayOptions = {},
|
||||||
|
) {
|
||||||
|
if (owner === "none") {
|
||||||
|
throw new Error("Discord audio player owner is required");
|
||||||
|
}
|
||||||
|
this.assertOwnerAvailable(owner);
|
||||||
|
|
||||||
const resource = createAudioResource(stream, {
|
const resource = createAudioResource(stream, {
|
||||||
inputType: StreamType.OggOpus,
|
inputType: options.inputType ?? StreamType.OggOpus,
|
||||||
|
inlineVolume: options.inlineVolume ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this.owner === owner) {
|
||||||
|
this.player.stop();
|
||||||
|
}
|
||||||
|
this.resource = resource;
|
||||||
|
this.owner = owner;
|
||||||
|
if (owner === "music") {
|
||||||
|
const nextVolume =
|
||||||
|
options.volume !== undefined
|
||||||
|
? this.normalizeVolume(options.volume)
|
||||||
|
: this.musicVolume;
|
||||||
|
this.musicVolume = nextVolume;
|
||||||
|
this.setResourceVolume(nextVolume);
|
||||||
|
}
|
||||||
this.player.play(resource);
|
this.player.play(resource);
|
||||||
|
this.connection?.subscribe(this.player);
|
||||||
}
|
}
|
||||||
|
|
||||||
public pause() {
|
public getStatus(): AudioPlayerStatus {
|
||||||
|
return this.player.state.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public pause(owner?: DiscordPlayerOwner) {
|
||||||
|
if (!this.canControl(owner)) return;
|
||||||
this.player.pause(true);
|
this.player.pause(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public unpause() {
|
public unpause(owner?: DiscordPlayerOwner): boolean {
|
||||||
this.player.unpause();
|
if (!this.canControl(owner)) return false;
|
||||||
|
return this.player.unpause();
|
||||||
}
|
}
|
||||||
|
|
||||||
public stop() {
|
public stop(owner?: DiscordPlayerOwner) {
|
||||||
|
if (!this.canControl(owner)) return;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
|
this.owner = "none";
|
||||||
|
this.resource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMusicVolume(): number {
|
||||||
|
return this.musicVolume;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setMusicVolume(volume: number): void {
|
||||||
|
const nextVolume = this.normalizeVolume(volume);
|
||||||
|
this.musicVolume = nextVolume;
|
||||||
|
if (this.owner === "music") {
|
||||||
|
this.setResourceVolume(nextVolume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertOwnerAvailable(owner: DiscordPlayerOwner): void {
|
||||||
|
if (this.owner !== "none" && this.owner !== owner) {
|
||||||
|
throw new Error(`Discord audio player is owned by ${this.owner}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private canControl(owner?: DiscordPlayerOwner): boolean {
|
||||||
|
return !owner || this.owner === "none" || this.owner === owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeVolume(volume: number): number {
|
||||||
|
if (!Number.isFinite(volume)) return this.musicVolume;
|
||||||
|
return Math.max(0, Math.min(1, volume));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setResourceVolume(volume: number): void {
|
||||||
|
if (!this.resource?.volume) return;
|
||||||
|
this.resource.volume.setVolume(volume);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
import type { Router } from "express";
|
import type { Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { AppError } from "../errors";
|
import { AppError } from "../errors";
|
||||||
import { createChildLogger } from "../logger";
|
|
||||||
import {
|
import {
|
||||||
getAnalysisQueueStatus,
|
getAnalysisQueueStatus,
|
||||||
queueMessageAnalysis,
|
queueMessageAnalysis,
|
||||||
} from "../moderation/aiAnalyzer";
|
} from "../moderation/aiAnalyzer";
|
||||||
import { getMessageById } from "../moderation/messageStore";
|
import { getMessageById } from "../moderation/messageStore";
|
||||||
|
|
||||||
const logger = createChildLogger("analysis-routes");
|
|
||||||
|
|
||||||
export function createAnalysisRoutes(): Router {
|
export function createAnalysisRoutes(): Router {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -41,8 +38,6 @@ export function createAnalysisRoutes(): Router {
|
|||||||
// Queue for analysis
|
// Queue for analysis
|
||||||
await queueMessageAnalysis(id);
|
await queueMessageAnalysis(id);
|
||||||
|
|
||||||
logger.info({ messageId: id }, "Message queued for re-analysis");
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
messageId: id,
|
messageId: id,
|
||||||
|
|||||||
@@ -1,27 +1,58 @@
|
|||||||
import type { Router } from "express";
|
import type { NextFunction, Request, Response, Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { AppError } from "../errors";
|
import { AppError } from "../errors";
|
||||||
import type { MediaController } from "../media/mediaController";
|
import type { MediaController } from "../media/mediaController";
|
||||||
|
import type { MediaMode } from "../media/mediaTypes";
|
||||||
|
|
||||||
export type MediaRouteController = Pick<
|
export type MediaRouteController = Pick<
|
||||||
MediaController,
|
MediaController,
|
||||||
"getState" | "queue" | "skip" | "stop"
|
"getState" | "queue" | "skip" | "stop" | "setMusicVolume"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export function createMediaRoutes(controller: MediaRouteController): Router {
|
export interface MediaRouteOptions {
|
||||||
const router = express.Router();
|
adminPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
router.get("/media/status", (_req, res, next) => {
|
export function createMediaRoutes(
|
||||||
|
controller: MediaRouteController,
|
||||||
|
options: MediaRouteOptions = {},
|
||||||
|
): Router {
|
||||||
|
const router = express.Router();
|
||||||
|
const { adminPassword } = options;
|
||||||
|
|
||||||
|
const adminAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!adminPassword) return next();
|
||||||
|
const authHeader = req.headers["x-admin-password"];
|
||||||
|
if (authHeader === adminPassword) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: "Unauthorized access to admin features" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply admin auth as router-level middleware so route stack ordering
|
||||||
|
// remains predictable for tests that inspect route handlers.
|
||||||
|
router.use(adminAuth);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/media/status",
|
||||||
|
(_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
res.json(controller.getState());
|
res.json(controller.getState());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.post("/media/queue", async (req, res, next) => {
|
router.post(
|
||||||
|
"/media/queue",
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { source } = req.body as { source?: string };
|
const { source, mode = "music" } = req.body as {
|
||||||
|
source?: string;
|
||||||
|
mode?: MediaMode;
|
||||||
|
};
|
||||||
if (!source) {
|
if (!source) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
"Media source is required",
|
"Media source is required",
|
||||||
@@ -29,27 +60,59 @@ export function createMediaRoutes(controller: MediaRouteController): Router {
|
|||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
res.json(await controller.queue(source));
|
if (mode !== "music" && mode !== "screen") {
|
||||||
|
throw new AppError("Invalid media mode", "INVALID_MEDIA_MODE", 400);
|
||||||
|
}
|
||||||
|
res.json(await controller.queue(source, { mode }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.post("/media/skip", async (_req, res, next) => {
|
router.post(
|
||||||
|
"/media/skip",
|
||||||
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
res.json(await controller.skip());
|
res.json(await controller.skip());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.post("/media/stop", async (_req, res, next) => {
|
router.post(
|
||||||
|
"/media/stop",
|
||||||
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
res.json(await controller.stop());
|
res.json(await controller.stop());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/media/volume",
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { volume } = req.body as { volume?: number };
|
||||||
|
if (typeof volume !== "number" || Number.isNaN(volume)) {
|
||||||
|
throw new AppError("Volume is required", "INVALID_VOLUME", 400);
|
||||||
|
}
|
||||||
|
if (volume < 0 || volume > 1) {
|
||||||
|
throw new AppError(
|
||||||
|
"Volume must be between 0 and 1",
|
||||||
|
"INVALID_VOLUME",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.json(await controller.setMusicVolume(volume));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export function createSyncRoutes(client: Client): Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (shouldSkipRecentBacklogSync(guildId, channelId)) {
|
if (shouldSkipRecentBacklogSync(guildId, channelId)) {
|
||||||
logger.debug({ guildId, channelId }, "Skipping recent backlog sync");
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
channelId,
|
channelId,
|
||||||
@@ -56,15 +55,8 @@ export function createSyncRoutes(client: Client): Router {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info({ guildId, channelId }, "Queueing backlog sync");
|
|
||||||
|
|
||||||
syncSelectedChannelBacklog(client, guildId, channelId)
|
syncSelectedChannelBacklog(client, guildId, channelId)
|
||||||
.then((count) => {
|
.then(() => {})
|
||||||
logger.info(
|
|
||||||
{ guildId, channelId, messagesSync: count },
|
|
||||||
"Backlog sync complete",
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface SharedUIState {
|
|||||||
selectedVoiceChannel: string;
|
selectedVoiceChannel: string;
|
||||||
selectedTextGuild: string;
|
selectedTextGuild: string;
|
||||||
selectedTextChannel: string;
|
selectedTextChannel: string;
|
||||||
activeTab: "voice" | "text";
|
activeTab: "voice" | "messages" | "media" | "review";
|
||||||
isListening: boolean;
|
isListening: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Router } from "express";
|
import type { NextFunction, Request, Response, Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { AppError } from "../errors";
|
import { AppError } from "../errors";
|
||||||
import { createChildLogger } from "../logger";
|
import { createChildLogger } from "../logger";
|
||||||
@@ -12,6 +12,7 @@ export interface VoiceRouteOptions {
|
|||||||
voiceController: VoiceController;
|
voiceController: VoiceController;
|
||||||
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
|
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
|
||||||
broadcaster: ModerationBroadcaster;
|
broadcaster: ModerationBroadcaster;
|
||||||
|
adminPassword?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createVoiceRoutes(
|
export function createVoiceRoutes(
|
||||||
@@ -25,6 +26,7 @@ export function createVoiceRoutes(
|
|||||||
| ((patch: Partial<SharedUIState>) => SharedUIState)
|
| ((patch: Partial<SharedUIState>) => SharedUIState)
|
||||||
| undefined;
|
| undefined;
|
||||||
let broadcaster: ModerationBroadcaster | undefined;
|
let broadcaster: ModerationBroadcaster | undefined;
|
||||||
|
let adminPassword: string | undefined;
|
||||||
|
|
||||||
if ("connect" in options && "disconnect" in options) {
|
if ("connect" in options && "disconnect" in options) {
|
||||||
// Old signature: just VoiceController
|
// Old signature: just VoiceController
|
||||||
@@ -35,10 +37,21 @@ export function createVoiceRoutes(
|
|||||||
voiceController = opts.voiceController;
|
voiceController = opts.voiceController;
|
||||||
patchSharedUIState = opts.patchSharedUIState;
|
patchSharedUIState = opts.patchSharedUIState;
|
||||||
broadcaster = opts.broadcaster;
|
broadcaster = opts.broadcaster;
|
||||||
|
adminPassword = opts.adminPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adminAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!adminPassword) return next();
|
||||||
|
const authHeader = req.headers["x-admin-password"];
|
||||||
|
if (authHeader === adminPassword) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: "Unauthorized access to admin features" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// GET /api/status - Get voice connection status
|
// GET /api/status - Get voice connection status
|
||||||
router.get("/status", (_req, res, next) => {
|
router.get("/status", (_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const status = voiceController.getStatus();
|
const status = voiceController.getStatus();
|
||||||
res.json(status);
|
res.json(status);
|
||||||
@@ -48,7 +61,7 @@ export function createVoiceRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/guilds - List available guilds
|
// GET /api/guilds - List available guilds
|
||||||
router.get("/guilds", (_req, res, next) => {
|
router.get("/guilds", (_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const guilds = voiceController.listGuilds();
|
const guilds = voiceController.listGuilds();
|
||||||
res.json(guilds);
|
res.json(guilds);
|
||||||
@@ -58,7 +71,9 @@ export function createVoiceRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/guilds/:guildId/voice-channels - List voice channels in a guild
|
// GET /api/guilds/:guildId/voice-channels - List voice channels in a guild
|
||||||
router.get("/guilds/:guildId/voice-channels", async (req, res, next) => {
|
router.get(
|
||||||
|
"/guilds/:guildId/voice-channels",
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { guildId } = req.params;
|
const { guildId } = req.params;
|
||||||
|
|
||||||
@@ -66,15 +81,20 @@ export function createVoiceRoutes(
|
|||||||
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const channels = await voiceController.listVoiceChannels(guildId);
|
const channels = await voiceController.listVoiceChannels(
|
||||||
|
guildId as string,
|
||||||
|
);
|
||||||
res.json(channels);
|
res.json(channels);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// GET /api/guilds/:guildId/channels - List text channels in a guild
|
// GET /api/guilds/:guildId/channels - List text channels in a guild
|
||||||
router.get("/guilds/:guildId/channels", async (req, res, next) => {
|
router.get(
|
||||||
|
"/guilds/:guildId/channels",
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { guildId } = req.params;
|
const { guildId } = req.params;
|
||||||
|
|
||||||
@@ -82,31 +102,21 @@ export function createVoiceRoutes(
|
|||||||
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const channels = await voiceController.listWatchableChannels(guildId);
|
const channels = await voiceController.listWatchableChannels(
|
||||||
|
guildId as string,
|
||||||
|
);
|
||||||
res.json(channels);
|
res.json(channels);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
// GET /api/guilds/:guildId/threads - List threads in a guild
|
|
||||||
router.get("/guilds/:guildId/threads", async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const { guildId } = req.params;
|
|
||||||
|
|
||||||
if (!guildId) {
|
|
||||||
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const threads = await voiceController.listThreads(guildId);
|
|
||||||
res.json(threads);
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/connect - Connect to a voice channel
|
// POST /api/connect - Connect to a voice channel
|
||||||
router.post("/connect", async (req, res, next) => {
|
router.post(
|
||||||
|
"/connect",
|
||||||
|
adminAuth,
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { guildId, channelId } = req.body as {
|
const { guildId, channelId } = req.body as {
|
||||||
guildId?: string;
|
guildId?: string;
|
||||||
@@ -138,10 +148,14 @@ export function createVoiceRoutes(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// POST /api/disconnect - Disconnect from voice channel
|
// POST /api/disconnect - Disconnect from voice channel
|
||||||
router.post("/disconnect", async (_req, res, next) => {
|
router.post(
|
||||||
|
"/disconnect",
|
||||||
|
adminAuth,
|
||||||
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
logger.info("Disconnecting from voice channel");
|
logger.info("Disconnecting from voice channel");
|
||||||
|
|
||||||
@@ -160,7 +174,8 @@ export function createVoiceRoutes(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
174
src/streaming/index.ts
Normal file
174
src/streaming/index.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { PassThrough } from "node:stream";
|
||||||
|
import type { Readable } from "node:stream";
|
||||||
|
import type { ChildProcess } from "node:child_process";
|
||||||
|
import type { Client } from "discord.js-selfbot-v13";
|
||||||
|
import {
|
||||||
|
Streamer as DankStreamer,
|
||||||
|
prepareStream as dankPrepareStream,
|
||||||
|
playStream as dankPlayStream,
|
||||||
|
Utils,
|
||||||
|
Encoders,
|
||||||
|
} from "@dank074/discord-video-stream";
|
||||||
|
|
||||||
|
type VoiceConnectionLike = any;
|
||||||
|
type StreamConnectionLike = any;
|
||||||
|
|
||||||
|
export interface StreamPlayOptions {
|
||||||
|
fps?: number;
|
||||||
|
bitrate?: number | string;
|
||||||
|
includeAudio?: boolean;
|
||||||
|
presetH26x?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamSession {
|
||||||
|
connection: VoiceConnectionLike;
|
||||||
|
stream: StreamConnectionLike;
|
||||||
|
play(source: string | Readable, options?: StreamPlayOptions): Promise<void>;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UtilsAPI = {
|
||||||
|
normalizeVideoCodec: (c: string) => c.toUpperCase?.() ?? c,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Streamer {
|
||||||
|
client: Client;
|
||||||
|
dankStreamer: DankStreamer;
|
||||||
|
|
||||||
|
constructor(client: Client) {
|
||||||
|
this.client = client;
|
||||||
|
this.dankStreamer = new DankStreamer(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(guildId: string, channelId: string): Promise<StreamSession> {
|
||||||
|
await this.dankStreamer.joinVoice(guildId, channelId);
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
let currentCommand: any = null;
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
try {
|
||||||
|
if (currentCommand?.kill) currentCommand.kill("SIGKILL");
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
this.dankStreamer.stopStream();
|
||||||
|
this.dankStreamer.leaveVoice();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
connection: {} as any,
|
||||||
|
stream: {} as any,
|
||||||
|
play: async (source: string | Readable, options: StreamPlayOptions = {}) => {
|
||||||
|
if (stopped) return;
|
||||||
|
|
||||||
|
let targetSource: string | Readable = source;
|
||||||
|
if (typeof source === "string" && source.includes("\n")) {
|
||||||
|
const urls = source.split("\n").filter((u) => u.trim());
|
||||||
|
targetSource = urls[0] ?? source;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fps = options.fps ?? 60;
|
||||||
|
const bitrateStr = String(options.bitrate ?? 8000).replace(/k$/i, "");
|
||||||
|
const bitrateVideo = parseInt(bitrateStr, 10) || 8000;
|
||||||
|
|
||||||
|
console.log("[Streamer] Starting screen share for source:", typeof targetSource === "string" ? targetSource.slice(0, 50) + "..." : "ReadableStream");
|
||||||
|
const { command, output } = dankPrepareStream(targetSource, {
|
||||||
|
encoder: Encoders.software({
|
||||||
|
x264: { preset: (options.presetH26x as any) ?? "ultrafast" },
|
||||||
|
x265: { preset: (options.presetH26x as any) ?? "ultrafast" },
|
||||||
|
}),
|
||||||
|
videoCodec: Utils.normalizeVideoCodec("H264"),
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
bitrateVideo: bitrateVideo,
|
||||||
|
frameRate: fps,
|
||||||
|
includeAudio: options.includeAudio !== false,
|
||||||
|
minimizeLatency: false,
|
||||||
|
customInputOptions: ["-fflags nobuffer"],
|
||||||
|
customHeaders: {
|
||||||
|
"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",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
currentCommand = command;
|
||||||
|
|
||||||
|
const webOutput = new PassThrough();
|
||||||
|
const discordOutput = new PassThrough();
|
||||||
|
|
||||||
|
output.pipe(webOutput);
|
||||||
|
output.pipe(discordOutput);
|
||||||
|
|
||||||
|
const globalAny: any = globalThis;
|
||||||
|
const onData = (chunk: Buffer) => {
|
||||||
|
try {
|
||||||
|
globalAny.broadcastVideoToWeb?.(chunk);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
webOutput.on("data", onData);
|
||||||
|
|
||||||
|
command.on("error", (err: Error) => {
|
||||||
|
console.error("[Streamer] Transcoder error:", err);
|
||||||
|
});
|
||||||
|
command.on("stderr", (stderrLine: string) => {
|
||||||
|
console.error("[Streamer] FFMPEG:", stderrLine);
|
||||||
|
});
|
||||||
|
command.on("end", () => {
|
||||||
|
console.log("[Streamer] FFMPEG process ended naturally.");
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("[Streamer] Calling dankPlayStream...");
|
||||||
|
await dankPlayStream(discordOutput, this.dankStreamer, undefined);
|
||||||
|
console.log("[Streamer] dankPlayStream completed successfully.");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Streamer] dankPlayStream error:", err);
|
||||||
|
} finally {
|
||||||
|
console.log("[Streamer] Cleaning up stream resources.");
|
||||||
|
webOutput.off("data", onData);
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareStream(source: string, _options: any): any {
|
||||||
|
return { command: null, output: new PassThrough() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function playStream(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStreamSession(
|
||||||
|
client: Client,
|
||||||
|
guildId: string,
|
||||||
|
channelId: string,
|
||||||
|
): Promise<StreamSession> {
|
||||||
|
return new Streamer(client).createSession(guildId, channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function playPreparedStream(
|
||||||
|
source: string | Readable,
|
||||||
|
session: StreamSession,
|
||||||
|
options: StreamPlayOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await session.play(source, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function playTranscodedPreparedStream(
|
||||||
|
source: string | Readable,
|
||||||
|
session: StreamSession,
|
||||||
|
options: StreamPlayOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await session.play(source, options);
|
||||||
|
}
|
||||||
158
src/streaming/transcoder.ts
Normal file
158
src/streaming/transcoder.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { spawn, ChildProcess } from "node:child_process";
|
||||||
|
import { PassThrough } from "node:stream";
|
||||||
|
import type { Readable } from "node:stream";
|
||||||
|
import { retryWithBackoff } from "../retry";
|
||||||
|
import { createChildLogger } from "../logger";
|
||||||
|
import { transcoderRestartsCounter, transcoderRunningGauge } from "../metrics";
|
||||||
|
|
||||||
|
const logger = createChildLogger("transcoder");
|
||||||
|
|
||||||
|
export interface TranscoderOptions {
|
||||||
|
fps?: number;
|
||||||
|
bitrate?: string | number;
|
||||||
|
preset?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Transcoder {
|
||||||
|
proc: ChildProcess | null = null;
|
||||||
|
output: Readable | null = null;
|
||||||
|
stopping = false;
|
||||||
|
restartAttempts = 0;
|
||||||
|
restartTimer: NodeJS.Timeout | null = null;
|
||||||
|
maxRestarts = 6;
|
||||||
|
|
||||||
|
constructor(private source: string, private opts: TranscoderOptions = {}) {}
|
||||||
|
|
||||||
|
start(): { command: ChildProcess; output: Readable } {
|
||||||
|
const fps = this.opts.fps ?? 30;
|
||||||
|
const bitrate = String(this.opts.bitrate ?? "2500k");
|
||||||
|
const preset = this.opts.preset ?? "superfast";
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"warning",
|
||||||
|
"-i",
|
||||||
|
this.source,
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
preset,
|
||||||
|
"-r",
|
||||||
|
String(fps),
|
||||||
|
"-s",
|
||||||
|
"1280x720",
|
||||||
|
"-b:v",
|
||||||
|
String(bitrate),
|
||||||
|
"-maxrate",
|
||||||
|
"4000k",
|
||||||
|
"-c:a",
|
||||||
|
"libopus",
|
||||||
|
"-f",
|
||||||
|
"matroska",
|
||||||
|
"-",
|
||||||
|
];
|
||||||
|
|
||||||
|
const cmd = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
const out = cmd.stdout ?? new PassThrough();
|
||||||
|
|
||||||
|
this.proc = cmd;
|
||||||
|
this.output = out;
|
||||||
|
|
||||||
|
cmd.on("error", (err) => {
|
||||||
|
logger.error({ err }, "transcoder process error");
|
||||||
|
});
|
||||||
|
cmd.on("exit", (code, signal) => {
|
||||||
|
logger.info({ code, signal }, "transcoder exited");
|
||||||
|
transcoderRunningGauge.set(0);
|
||||||
|
// If we didn't explicitly stop, attempt restart with backoff
|
||||||
|
if (!this.stopping) {
|
||||||
|
this.scheduleRestart();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
transcoderRunningGauge.set(1);
|
||||||
|
|
||||||
|
return { command: cmd, output: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.stopping = true;
|
||||||
|
if (this.restartTimer) {
|
||||||
|
clearTimeout(this.restartTimer);
|
||||||
|
this.restartTimer = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (this.proc && !this.proc.killed) this.proc.kill("SIGTERM");
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn({ e }, "failed to terminate transcoder gracefully");
|
||||||
|
try {
|
||||||
|
if (this.proc && !this.proc.killed) this.proc.kill("SIGKILL");
|
||||||
|
} catch (e2) {
|
||||||
|
logger.warn({ e2 }, "failed to kill transcoder forcefully");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.proc = null;
|
||||||
|
this.output = null;
|
||||||
|
transcoderRunningGauge.set(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleRestart() {
|
||||||
|
if (this.restartAttempts >= this.maxRestarts) {
|
||||||
|
logger.error({ attempts: this.restartAttempts }, "transcoder reached max restart attempts");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = Math.min(30000, 1000 * Math.pow(2, this.restartAttempts));
|
||||||
|
this.restartAttempts += 1;
|
||||||
|
transcoderRestartsCounter.inc();
|
||||||
|
logger.info({ delay, attempt: this.restartAttempts }, "scheduling transcoder restart");
|
||||||
|
this.restartTimer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
this.start();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "transcoder restart failed");
|
||||||
|
this.scheduleRestart();
|
||||||
|
}
|
||||||
|
}, delay) as unknown as NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
async startWithRetry(retries = 2) {
|
||||||
|
return retryWithBackoff(() => Promise.resolve(this.start()), {
|
||||||
|
retries,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
this.stopping = true;
|
||||||
|
if (this.restartTimer) {
|
||||||
|
clearTimeout(this.restartTimer);
|
||||||
|
this.restartTimer = null;
|
||||||
|
}
|
||||||
|
if (this.proc && !this.proc.killed) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
this.proc?.once("exit", () => resolve());
|
||||||
|
try {
|
||||||
|
this.proc?.kill("SIGTERM");
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
this.proc?.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(() => resolve(), 5000);
|
||||||
|
}).then(() => {
|
||||||
|
this.proc = null;
|
||||||
|
this.output = null;
|
||||||
|
transcoderRunningGauge.set(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareTranscoder(source: string, options: TranscoderOptions = {}) {
|
||||||
|
const t = new Transcoder(source, options);
|
||||||
|
const { command, output } = t.start();
|
||||||
|
return { transcoder: t, command, output };
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user