Compare commits
155 Commits
be6c9f8132
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8584030633 | ||
|
|
f35710db3f | ||
|
|
0c7930bd01 | ||
|
|
e22e620bae | ||
|
|
eda32720c8 | ||
|
|
c0f66c78a3 | ||
|
|
b8a6f40b1b | ||
|
|
4931e6d1ca | ||
|
|
a3e6c4695a | ||
|
|
6de5342703 | ||
|
|
5a926dbd17 | ||
|
|
7985efbef6 | ||
|
|
71889ab689 | ||
|
|
518577d79d | ||
|
|
d04093ec6e | ||
|
|
05feb697f0 | ||
|
|
a5b5ccf5b0 | ||
|
|
99ec528a03 | ||
|
|
7dedac2094 | ||
|
|
9b211f05cf | ||
|
|
4825dc6d4d | ||
|
|
9ad7d16a17 | ||
|
|
62d131cf14 | ||
|
|
82025a19b2 | ||
|
|
3c7d722973 | ||
|
|
70931576dc | ||
|
|
8e1f5adaa4 | ||
|
|
2744e7035b | ||
|
|
8b33af8286 | ||
|
|
d50ce8698f | ||
|
|
e32e092596 | ||
|
|
6ac4a5c11a | ||
|
|
119258c2b0 | ||
|
|
5abe5cc39f | ||
|
|
c954cc0406 | ||
|
|
95ea0cee75 | ||
|
|
2e30a063d2 | ||
|
|
6aeabc690f | ||
|
|
73901231c3 | ||
|
|
61b07e4b01 | ||
|
|
a97feb1e2a | ||
|
|
575302db57 | ||
|
|
76470a5129 | ||
|
|
ff2792d403 | ||
|
|
192f83d31d | ||
|
|
06b6db703c | ||
|
|
94e497b7a6 | ||
|
|
b00def2d4d | ||
|
|
dbae042279 | ||
|
|
c509f48f95 | ||
|
|
1e0a00d82d | ||
|
|
9e07a0a1f3 | ||
|
|
acb43b6dac | ||
|
|
93134a9793 | ||
|
|
2194d4a8b6 | ||
|
|
3b6bf49160 | ||
|
|
d42d3f8def | ||
|
|
ed438e6fc0 | ||
|
|
6859eb3f50 | ||
|
|
5d015ea6e1 | ||
|
|
12fdc713d9 | ||
|
|
ff90f9e95c | ||
|
|
2c58e44c67 | ||
|
|
bd03fe1817 | ||
|
|
235c1120c2 | ||
|
|
930c399484 | ||
|
|
958a6d7236 | ||
|
|
bbd65d369e | ||
|
|
bbd3a88471 | ||
|
|
ed10f45e8c | ||
|
|
29fcde69e4 | ||
|
|
0060d1ba95 | ||
|
|
895b47890c | ||
|
|
e5aa398e5c | ||
|
|
954faf6c5a | ||
|
|
5010a4d1f1 | ||
|
|
203aa9a589 | ||
|
|
196ca1b784 | ||
|
|
a812182218 | ||
|
|
cb2cfc76f2 | ||
|
|
ae02556f75 | ||
|
|
fbf0f11db1 | ||
|
|
0a7965e1d4 | ||
|
|
2d56012bf5 | ||
|
|
c3f2d01e9b | ||
|
|
298048aaaf | ||
|
|
71a28085d2 | ||
|
|
da5e191b0f | ||
|
|
558d65342b | ||
|
|
44368e646f | ||
|
|
dbfc6866f9 | ||
|
|
d8aeefb739 | ||
|
|
08cd515d8a | ||
|
|
c81a499535 | ||
|
|
3fb1fcb72c | ||
|
|
243a18ecad | ||
|
|
f14e893cb7 | ||
|
|
54cd4e0386 | ||
|
|
65ab5ecb32 | ||
|
|
81253e4ffe | ||
|
|
ce35b335d0 | ||
|
|
2b4e2a7ab7 | ||
|
|
2d511e08db | ||
|
|
13078e7c3c | ||
|
|
298fc968cf | ||
|
|
7a8883f623 | ||
|
|
0c54ac4ba8 | ||
|
|
9f5f8a3090 | ||
|
|
8ab7aaa32d | ||
|
|
01dc9b1836 | ||
|
|
49d4bbf781 | ||
|
|
4fbbc056bb | ||
|
|
47ae7f8650 | ||
|
|
35269b5bef | ||
|
|
c63a61460c | ||
|
|
9889d20edd | ||
|
|
b580430eb6 | ||
|
|
b9d0a06d01 | ||
|
|
b600dad011 | ||
|
|
50d4517079 | ||
|
|
9ff0f0bede | ||
|
|
1c4b0afbce | ||
|
|
dfe3444018 | ||
|
|
7e528a473b | ||
|
|
4e28cf9671 | ||
|
|
52b36c963f | ||
|
|
b833b6d978 | ||
|
|
d1282f2f57 | ||
|
|
1623c612c3 | ||
|
|
8c9e8aa64d | ||
|
|
c5297da795 | ||
|
|
dbc11bbd16 | ||
|
|
3c918692cb | ||
|
|
94a3acf12e | ||
|
|
84e20ae373 | ||
|
|
caf90ea9e6 | ||
|
|
818a059121 | ||
|
|
0eee7b9390 | ||
|
|
81bb9cc6ab | ||
|
|
4ff79bea73 | ||
|
|
9ff1261239 | ||
|
|
bb7e3885ac | ||
|
|
c31c4df15e | ||
|
|
93eb2303c7 | ||
|
|
54a4096323 | ||
|
|
1c945b9cac | ||
|
|
0060c4a097 | ||
|
|
5aa57f884f | ||
|
|
d5977c8845 | ||
|
|
6dc6a31ea7 | ||
|
|
54534fe84c | ||
|
|
eb27d36cce | ||
|
|
0a5cedfed1 | ||
|
|
d4a4f737a8 | ||
|
|
6e203604ec |
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
|
||||
33
.env.example
33
.env.example
@@ -25,12 +25,21 @@ WEBSERVER_PORT=3000
|
||||
VOICE_CONNECTION_TIMEOUT_MS=15000
|
||||
RECONNECT_TIMEOUT_MS=5000
|
||||
|
||||
# Voice Recording Selection
|
||||
# VOICE_GUILD_ID falls back to legacy GUILD_ID when omitted.
|
||||
GUILD_ID=legacy_voice_guild_id
|
||||
VOICE_GUILD_ID=voice_guild_id
|
||||
VOICE_CHANNEL_ID=voice_channel_id
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL=info
|
||||
NODE_ENV=development
|
||||
|
||||
# Moderation Configuration
|
||||
MONITOR_GUILD_ID=your_guild_id_here
|
||||
# TEXT_GUILD_ID falls back to legacy MONITOR_GUILD_ID when omitted.
|
||||
MONITOR_GUILD_ID=legacy_text_guild_id
|
||||
TEXT_GUILD_ID=text_guild_id
|
||||
TEXT_CHANNEL_ID=text_channel_id
|
||||
PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload
|
||||
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
|
||||
ATTACHMENT_MAX_SIZE_MB=100
|
||||
@@ -40,11 +49,27 @@ BACKLOG_SYNC_BATCH_SIZE=100
|
||||
|
||||
# AI Analysis Configuration
|
||||
AI_ANALYSIS_ENABLED=false
|
||||
OPENAI_MODERATION_API_KEY=your_openai_moderation_key_here
|
||||
OPENAI_MODERATION_BASE_URL=https://api.openai.com/v1
|
||||
OPENAI_MODERATION_MODEL=omni-moderation-latest
|
||||
AI_LLM_API_KEY=your_9router_key_here
|
||||
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
|
||||
AI_LLM_MODEL=free
|
||||
AI_ANALYSIS_TIMEOUT_MS=30000
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_TYPE=sqlite
|
||||
# DATABASE_TYPE=postgres
|
||||
|
||||
# PostgreSQL Configuration (used when DATABASE_TYPE=postgres)
|
||||
# Option 1: Use DATABASE_URL for connection string
|
||||
# DATABASE_URL=postgresql://user:password@localhost:5432/discord_bot
|
||||
|
||||
# Option 2: Use individual connection parameters
|
||||
# POSTGRES_HOST=localhost
|
||||
# POSTGRES_PORT=5432
|
||||
# POSTGRES_USER=postgres
|
||||
# POSTGRES_PASSWORD=your_password_here
|
||||
# POSTGRES_DB=discord_bot
|
||||
|
||||
# PostgreSQL Connection Pool Configuration
|
||||
# POSTGRES_POOL_MIN=2
|
||||
# POSTGRES_POOL_MAX=10
|
||||
|
||||
|
||||
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
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,4 +2,7 @@ node_modules
|
||||
recordings
|
||||
.env
|
||||
dist/
|
||||
.muxer-queue.**
|
||||
public/app/
|
||||
.muxer-queue.**
|
||||
.claude/
|
||||
.env.test
|
||||
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
[submodule "vendor/discord.js-selfbot-v13"]
|
||||
path = vendor/discord.js-selfbot-v13
|
||||
url = ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.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"]
|
||||
223
README.md
223
README.md
@@ -1,103 +1,184 @@
|
||||
# 🎙️ Discord Voice Recorder Bot
|
||||
# Discord Moderation Watcher Bot
|
||||
|
||||
Bot Discord yang **otomatis join ke voice channel** saat startup dan **merekam suara** semua pengguna yang bicara. File audio disimpan secara lokal dalam format `.ogg`.
|
||||
Bot monitoring Discord yang merekam voice channel, menangkap pesan teks, menyimpan attachment, menjalankan analisis opsional, dan menyediakan dashboard web real-time.
|
||||
|
||||
Dibangun dengan **Bun** + **discord.js** + **@discordjs/voice**.
|
||||
Stack utama: Node.js, pnpm, TypeScript, `discord.js-selfbot-v13`, `@discordjs/voice`, Express, WebSocket, Drizzle ORM, SQLite/PostgreSQL, React, Vite, Vitest, dan Biome.
|
||||
|
||||
---
|
||||
## Prasyarat
|
||||
|
||||
## 📋 Prasyarat
|
||||
- Node.js versi modern yang kompatibel dengan TypeScript dan Vite.
|
||||
- pnpm 10.x. Repo ini dipin ke `pnpm@10.25.0`.
|
||||
- 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`).
|
||||
|
||||
- [Bun](https://bun.sh) >= 1.0
|
||||
- FFmpeg (untuk encoding audio)
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install ffmpeg
|
||||
|
||||
# Arch
|
||||
sudo pacman -S ffmpeg
|
||||
```
|
||||
- Discord Bot dengan permission:
|
||||
- `Connect` (join voice channel)
|
||||
- `Use Voice Activity`
|
||||
- `Read Messages/View Channels`
|
||||
- Privileged Intents: **Server Members Intent** (aktifkan di Developer Portal)
|
||||
Install FFmpeg:
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Setup
|
||||
|
||||
### 1. Clone & Install
|
||||
```bash
|
||||
cd /path/to/bot
|
||||
bun install
|
||||
# Ubuntu/Debian
|
||||
sudo apt install ffmpeg
|
||||
|
||||
# Arch
|
||||
sudo pacman -S ffmpeg
|
||||
```
|
||||
|
||||
### 2. Konfigurasi `.env`
|
||||
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
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
Edit `.env` sesuai server yang dimonitor:
|
||||
|
||||
```env
|
||||
DISCORD_TOKEN=your_bot_token_here
|
||||
VOICE_CHANNEL_ID=your_voice_channel_id_here
|
||||
GUILD_ID=your_guild_id_here
|
||||
DISCORD_TOKEN=your_token_here
|
||||
MONITOR_GUILD_ID=your_guild_id_here
|
||||
RECORDINGS_DIR=./recordings
|
||||
WEBSERVER_PORT=3000
|
||||
DATABASE_TYPE=sqlite
|
||||
```
|
||||
|
||||
**Cara mendapatkan ID:**
|
||||
- Aktifkan **Developer Mode** di Discord (Settings → Advanced → Developer Mode)
|
||||
- Klik kanan pada voice channel → **Copy Channel ID** → paste ke `VOICE_CHANNEL_ID`
|
||||
- Klik kanan pada server/guild → **Copy Server ID** → paste ke `GUILD_ID`
|
||||
- Token bot dari [Discord Developer Portal](https://discord.com/developers/applications) → Bot → Reset Token
|
||||
Catatan: project ini memakai selfbot library, bukan bot token Discord standar. Pastikan penggunaan sesuai risiko dan aturan platform yang berlaku.
|
||||
|
||||
### 3. Invite Bot ke Server
|
||||
Di Developer Portal → OAuth2 → URL Generator:
|
||||
- Scopes: `bot`
|
||||
- Bot Permissions: `Connect`, `Use Voice Activity`, `View Channels`
|
||||
|
||||
Copy URL, buka di browser, pilih server.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Menjalankan Bot
|
||||
## Menjalankan
|
||||
|
||||
```bash
|
||||
# Development (auto-restart saat file berubah)
|
||||
bun run dev
|
||||
# Bot/server utama dengan auto-restart
|
||||
pnpm run dev
|
||||
|
||||
# Production
|
||||
bun run start
|
||||
# Production-style start
|
||||
pnpm run start
|
||||
|
||||
# Dashboard frontend dev server
|
||||
pnpm run dev:web
|
||||
```
|
||||
|
||||
Bot akan langsung join ke voice channel yang ditentukan dalam `.env`.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Struktur File Rekaman
|
||||
Dashboard build production disajikan dari `public/app` setelah menjalankan:
|
||||
|
||||
```bash
|
||||
pnpm run build:web
|
||||
```
|
||||
|
||||
## Command Development
|
||||
|
||||
```bash
|
||||
# Type checking
|
||||
pnpm run typecheck
|
||||
|
||||
# Lint
|
||||
pnpm run lint
|
||||
|
||||
# Format
|
||||
pnpm run format
|
||||
|
||||
# Test
|
||||
pnpm run test
|
||||
|
||||
# Build frontend + TypeScript
|
||||
pnpm run build
|
||||
|
||||
# Install external yt-dlp CLI for YouTube/search/Spotify track playback
|
||||
pnpm run install:yt-dlp
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
Default database adalah SQLite di `.muxer-queue.db`. PostgreSQL dapat dipakai dengan `DATABASE_TYPE=postgres` dan konfigurasi `DATABASE_URL` atau variabel `POSTGRES_*`.
|
||||
|
||||
```bash
|
||||
# Generate migration Drizzle
|
||||
pnpm run db:generate
|
||||
|
||||
# Jalankan migration via drizzle-kit
|
||||
pnpm run db:migrate
|
||||
|
||||
# Jalankan migration programmatic
|
||||
pnpm run db:migrate:programmatic
|
||||
|
||||
# Buka Drizzle Studio
|
||||
pnpm run db:studio
|
||||
```
|
||||
|
||||
## Fitur
|
||||
|
||||
- Voice recording ke segment `.ogg` per user.
|
||||
- Metadata JSON per segment audio.
|
||||
- Text message capture untuk pesan baru, edit, dan delete.
|
||||
- Attachment capture dan upload ke endpoint Picser.
|
||||
- SQLite/PostgreSQL via Drizzle ORM.
|
||||
- REST API dan WebSocket untuk dashboard.
|
||||
- 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.
|
||||
- Retry dengan backoff untuk operasi eksternal.
|
||||
- AI moderation analysis opsional via konfigurasi `AI_*`.
|
||||
|
||||
## Struktur Rekaman
|
||||
|
||||
```text
|
||||
recordings/
|
||||
├── 123456789-1709900000000.ogg # <user-id>-<timestamp>.ogg
|
||||
├── 987654321-1709900001234.ogg
|
||||
└── ...
|
||||
<user-id>/
|
||||
<user-id>-<session-start>-0.ogg
|
||||
<user-id>-<session-start>-0.json
|
||||
<user-id>-<session-start>-1.ogg
|
||||
<user-id>-<session-start>-1.json
|
||||
```
|
||||
|
||||
Setiap kali user bicara dan berhenti (>1 detik diam), satu file `.ogg` baru dibuat.
|
||||
Segment duration dikontrol oleh `RECORDING_SEGMENT_MS`.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Struktur Proyek
|
||||
## Struktur Proyek
|
||||
|
||||
```text
|
||||
src/
|
||||
index.ts Entry point Discord client dan server
|
||||
recorder.ts Voice recording pipeline
|
||||
recorder/ Audio stream, decoder, segment metadata
|
||||
moderation/ Message capture, storage, uploads, AI review
|
||||
database/ Drizzle setup, schema, migrations
|
||||
routes/ Express route modules
|
||||
webserver.ts Express + WebSocket server
|
||||
retry.ts Retry helper berbasis p-retry
|
||||
audio/ffmpegProcess.ts Direct ffmpeg process wrapper
|
||||
frontend/ React dashboard source
|
||||
public/app/ Dashboard build output
|
||||
tests/ Vitest tests
|
||||
drizzle/migrations/ Database migrations
|
||||
```
|
||||
bot/
|
||||
├── src/
|
||||
│ ├── index.ts # Entry point — login & auto-join
|
||||
│ └── recorder.ts # Core recording logic
|
||||
├── recordings/ # File audio tersimpan (otomatis dibuat)
|
||||
├── .env # Konfigurasi (buat dari .env.example)
|
||||
├── .env.example
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
|
||||
## Konfigurasi Penting
|
||||
|
||||
Lihat `.env.example` untuk daftar lengkap. Variabel utama:
|
||||
|
||||
- `DISCORD_TOKEN` — token akun/client yang dipakai selfbot.
|
||||
- `MONITOR_GUILD_ID` — guild yang dimonitor untuk moderation capture.
|
||||
- `RECORDINGS_DIR` — direktori output audio.
|
||||
- `WEBSERVER_PORT` — port HTTP/WebSocket.
|
||||
- `DATABASE_TYPE` — `sqlite` atau `postgres`.
|
||||
- `PICSER_UPLOAD_URL` — endpoint upload attachment.
|
||||
- `AI_ANALYSIS_ENABLED` — aktifkan/nonaktifkan analisis AI.
|
||||
- `AI_LLM_API_KEY`, `AI_LLM_BASE_URL`, `AI_LLM_MODEL` — konfigurasi provider LLM.
|
||||
|
||||
## Verifikasi Setelah Perubahan
|
||||
|
||||
Sebelum menjalankan lama atau deploy, jalankan:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run typecheck
|
||||
pnpm run lint
|
||||
pnpm run test
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Catatan Library Modernization
|
||||
|
||||
Project memakai Zod untuk validasi runtime, Drizzle untuk database, dan wrapper `node:child_process` langsung untuk FFmpeg. Library lama `class-transformer`, `class-validator`, dan `fluent-ffmpeg` sudah tidak dipakai.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"files": {
|
||||
"includes": ["src/**/*.ts", "tests/**/*.ts", "*.json", "*.ts"]
|
||||
"includes": ["src/**/*.ts", "tests/**/*.ts", "*.json", "*.ts", "!vendor/**"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
@@ -14,9 +14,6 @@
|
||||
"style": {
|
||||
"noNonNullAssertion": "warn",
|
||||
"useNodejsImportProtocol": "warn"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
1854
docs/superpowers/plans/2026-05-14-ai-message-flow-react-dashboard.md
Normal file
1854
docs/superpowers/plans/2026-05-14-ai-message-flow-react-dashboard.md
Normal file
File diff suppressed because it is too large
Load Diff
704
docs/superpowers/plans/2026-05-14-drizzle-orm-migration.md
Normal file
704
docs/superpowers/plans/2026-05-14-drizzle-orm-migration.md
Normal file
@@ -0,0 +1,704 @@
|
||||
# Drizzle ORM Migration 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:** Replace raw SQL queries and manual database adapter with Drizzle ORM, providing type-safe database operations, automatic migrations, and better maintainability while supporting both SQLite and PostgreSQL.
|
||||
|
||||
**Architecture:** Replace the custom DatabaseAdapter pattern with Drizzle ORM's unified API. Define schema using Drizzle's TypeScript schema definitions. Replace all raw SQL queries in muxer-queue.ts and messageStore.ts with Drizzle query builder. Use Drizzle migrations for schema management. Maintain backward compatibility with existing data.
|
||||
|
||||
**Tech Stack:** drizzle-orm, drizzle-kit, better-sqlite3 (SQLite), postgres (PostgreSQL), TypeScript
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**New files to create:**
|
||||
- `src/database/schema.ts` — Drizzle schema definitions for all tables
|
||||
- `src/database/drizzle.ts` — Drizzle database client initialization
|
||||
- `drizzle.config.ts` — Drizzle Kit configuration
|
||||
- `drizzle/migrations/` — Auto-generated migration files
|
||||
|
||||
**Modified files:**
|
||||
- `src/muxer-queue.ts` — Replace raw SQL with Drizzle queries
|
||||
- `src/moderation/messageStore.ts` — Replace raw SQL with Drizzle queries
|
||||
- `src/database/adapter.ts` — Remove (no longer needed)
|
||||
- `src/database/postgres.ts` — Remove (Drizzle handles this)
|
||||
- `src/database/migrations.ts` — Remove (Drizzle handles this)
|
||||
- `src/index.ts` — Update database initialization
|
||||
- `src/webserver.ts` — Update database calls
|
||||
- `package.json` — Add drizzle-orm, drizzle-kit dependencies
|
||||
- `src/config.ts` — Keep PostgreSQL config variables
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add Drizzle Dependencies
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
|
||||
- [ ] **Step 1: Add drizzle-orm and drizzle-kit**
|
||||
|
||||
```bash
|
||||
cd /mnt/code/bete && pnpm add drizzle-orm
|
||||
```
|
||||
|
||||
Expected: drizzle-orm installed
|
||||
|
||||
- [ ] **Step 2: Add drizzle-kit as dev dependency**
|
||||
|
||||
```bash
|
||||
cd /mnt/code/bete && pnpm add -D drizzle-kit
|
||||
```
|
||||
|
||||
Expected: drizzle-kit installed
|
||||
|
||||
- [ ] **Step 3: Verify installation**
|
||||
|
||||
```bash
|
||||
cd /mnt/code/bete && pnpm list drizzle-orm drizzle-kit
|
||||
```
|
||||
|
||||
Expected: Both packages listed with versions
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json pnpm-lock.yaml
|
||||
git commit -m "feat: add drizzle-orm and drizzle-kit dependencies"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create Drizzle Schema Definitions
|
||||
|
||||
**Files:**
|
||||
- Create: `src/database/schema.ts`
|
||||
|
||||
- [ ] **Step 1: Create schema.ts with table definitions**
|
||||
|
||||
```typescript
|
||||
import { pgTable, text, integer, bigint, real, index, foreignKey } from "drizzle-orm/pg-core";
|
||||
import { sqliteTable, SQLiteInteger, SQLiteText } from "drizzle-orm/sqlite-core";
|
||||
import { config } from "../config";
|
||||
|
||||
// Determine which table function to use based on database type
|
||||
const tableFactory = config.DATABASE_TYPE === "postgres" ? pgTable : sqliteTable;
|
||||
|
||||
// Muxer Jobs Table
|
||||
export const muxerJobs = tableFactory("muxer_jobs", {
|
||||
id: text("id").primaryKey(),
|
||||
data: text("data").notNull(),
|
||||
status: text("status", { enum: ["pending", "processing", "completed", "failed"] }).notNull().default("pending"),
|
||||
attempts: integer("attempts").notNull().default(0),
|
||||
maxAttempts: integer("maxAttempts").notNull().default(3),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: bigint("updatedAt", { mode: "number" }).notNull(),
|
||||
error: text("error"),
|
||||
}, (table) => ({
|
||||
statusIdx: index("idx_muxer_jobs_status").on(table.status),
|
||||
createdAtIdx: index("idx_muxer_jobs_createdAt").on(table.createdAt),
|
||||
}));
|
||||
|
||||
// Messages Table
|
||||
export const messages = tableFactory("messages", {
|
||||
id: text("id").primaryKey(),
|
||||
guild_id: text("guild_id").notNull(),
|
||||
channel_id: text("channel_id").notNull(),
|
||||
thread_id: text("thread_id"),
|
||||
user_id: text("user_id").notNull(),
|
||||
username: text("username").notNull(),
|
||||
avatar_url: text("avatar_url"),
|
||||
content: text("content").notNull(),
|
||||
edited_content: text("edited_content"),
|
||||
created_at: bigint("created_at", { mode: "number" }).notNull(),
|
||||
edited_at: bigint("edited_at", { mode: "number" }),
|
||||
deleted_at: bigint("deleted_at", { mode: "number" }),
|
||||
type: text("type", { enum: ["text", "edited", "deleted"] }).notNull().default("text"),
|
||||
metadata: text("metadata"),
|
||||
ai_status: text("ai_status", { enum: ["pending", "clean", "warn", "flagged", "error"] }).notNull().default("pending"),
|
||||
ai_moderation_flags: text("ai_moderation_flags"),
|
||||
ai_moderation_score: real("ai_moderation_score"),
|
||||
ai_moderation_raw: text("ai_moderation_raw"),
|
||||
ai_analysis: text("ai_analysis"),
|
||||
ai_analyzed_at: bigint("ai_analyzed_at", { mode: "number" }),
|
||||
ai_error: text("ai_error"),
|
||||
}, (table) => ({
|
||||
channelIdx: index("idx_messages_channel").on(table.channel_id),
|
||||
userIdx: index("idx_messages_user").on(table.user_id),
|
||||
createdIdx: index("idx_messages_created").on(table.created_at),
|
||||
threadIdx: index("idx_messages_thread").on(table.thread_id),
|
||||
}));
|
||||
|
||||
// Attachments Table
|
||||
export const attachments = tableFactory("attachments", {
|
||||
id: text("id").primaryKey(),
|
||||
message_id: text("message_id").notNull(),
|
||||
guild_id: text("guild_id").notNull(),
|
||||
channel_id: text("channel_id").notNull(),
|
||||
thread_id: text("thread_id"),
|
||||
user_id: text("user_id").notNull(),
|
||||
filename: text("filename").notNull(),
|
||||
size: integer("size").notNull(),
|
||||
type: text("type").notNull(),
|
||||
discord_url: text("discord_url").notNull(),
|
||||
uploaded_url: text("uploaded_url"),
|
||||
upload_status: text("upload_status", { enum: ["pending", "uploaded", "failed"] }).notNull().default("pending"),
|
||||
upload_error: text("upload_error"),
|
||||
created_at: bigint("created_at", { mode: "number" }).notNull(),
|
||||
uploaded_at: bigint("uploaded_at", { mode: "number" }),
|
||||
}, (table) => ({
|
||||
channelIdx: index("idx_attachments_channel").on(table.channel_id),
|
||||
messageIdx: index("idx_attachments_message").on(table.message_id),
|
||||
statusIdx: index("idx_attachments_status").on(table.upload_status),
|
||||
fk: foreignKey({
|
||||
columns: [table.message_id],
|
||||
foreignColumns: [messages.id],
|
||||
}).onDelete("cascade"),
|
||||
}));
|
||||
|
||||
// UI State Table
|
||||
export const uiState = tableFactory("ui_state", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
updated_at: bigint("updated_at", { mode: "number" }).notNull(),
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run typecheck**
|
||||
|
||||
```bash
|
||||
cd /mnt/code/bete && pnpm run typecheck
|
||||
```
|
||||
|
||||
Expected: No TypeScript errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/database/schema.ts
|
||||
git commit -m "feat: create drizzle schema definitions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create Drizzle Configuration
|
||||
|
||||
**Files:**
|
||||
- Create: `drizzle.config.ts`
|
||||
|
||||
- [ ] **Step 1: Create drizzle.config.ts**
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
import { config } from "./src/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/database/schema.ts",
|
||||
out: "./drizzle/migrations",
|
||||
dialect: config.DATABASE_TYPE === "postgres" ? "postgresql" : "sqlite",
|
||||
dbCredentials: config.DATABASE_TYPE === "postgres"
|
||||
? {
|
||||
host: config.POSTGRES_HOST,
|
||||
port: config.POSTGRES_PORT,
|
||||
user: config.POSTGRES_USER,
|
||||
password: config.POSTGRES_PASSWORD,
|
||||
database: config.POSTGRES_DB,
|
||||
}
|
||||
: {
|
||||
url: `file:./.muxer-queue.db`,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add migration scripts to package.json**
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Generate initial migration**
|
||||
|
||||
```bash
|
||||
cd /mnt/code/bete && pnpm run db:generate
|
||||
```
|
||||
|
||||
Expected: Migration files created in drizzle/migrations/
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add drizzle.config.ts package.json drizzle/
|
||||
git commit -m "feat: add drizzle configuration and initial migrations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Create Drizzle Database Client
|
||||
|
||||
**Files:**
|
||||
- Create: `src/database/drizzle.ts`
|
||||
|
||||
- [ ] **Step 1: Create drizzle.ts**
|
||||
|
||||
```typescript
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3";
|
||||
import Database from "better-sqlite3";
|
||||
import { Pool } from "pg";
|
||||
import { config } from "../config";
|
||||
import { createChildLogger } from "../logger";
|
||||
import * as schema from "./schema";
|
||||
|
||||
const logger = createChildLogger("drizzle");
|
||||
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export async function initializeDatabase() {
|
||||
if (db) return db;
|
||||
|
||||
if (config.DATABASE_TYPE === "postgres") {
|
||||
const pool = new Pool({
|
||||
host: config.POSTGRES_HOST,
|
||||
port: config.POSTGRES_PORT,
|
||||
user: config.POSTGRES_USER,
|
||||
password: config.POSTGRES_PASSWORD,
|
||||
database: config.POSTGRES_DB,
|
||||
min: config.POSTGRES_POOL_MIN,
|
||||
max: config.POSTGRES_POOL_MAX,
|
||||
});
|
||||
|
||||
db = drizzle(pool, { schema });
|
||||
logger.info("PostgreSQL database initialized");
|
||||
} else {
|
||||
const sqlite = new Database(".muxer-queue.db");
|
||||
sqlite.pragma("journal_mode = WAL");
|
||||
db = drizzleSqlite(sqlite, { schema });
|
||||
logger.info("SQLite database initialized");
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getDatabase() {
|
||||
if (!db) {
|
||||
throw new Error("Database not initialized. Call initializeDatabase() first.");
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeDatabase() {
|
||||
if (db) {
|
||||
// Drizzle doesn't have a close method, but we can close the underlying connection
|
||||
if (config.DATABASE_TYPE === "postgres") {
|
||||
// Pool will be closed when the process exits
|
||||
logger.info("PostgreSQL connection pool will close on process exit");
|
||||
} else {
|
||||
logger.info("SQLite database closed");
|
||||
}
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run typecheck**
|
||||
|
||||
```bash
|
||||
cd /mnt/code/bete && pnpm run typecheck
|
||||
```
|
||||
|
||||
Expected: No TypeScript errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/database/drizzle.ts
|
||||
git commit -m "feat: create drizzle database client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Migrate muxer-queue.ts to Drizzle
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/muxer-queue.ts`
|
||||
|
||||
- [ ] **Step 1: Replace imports**
|
||||
|
||||
Replace:
|
||||
```typescript
|
||||
import { getDatabase, DatabaseAdapter } from "./database/adapter";
|
||||
```
|
||||
|
||||
With:
|
||||
```typescript
|
||||
import { getDatabase, initializeDatabase } from "./database/drizzle";
|
||||
import { muxerJobs } from "./database/schema";
|
||||
import { eq, asc, desc } from "drizzle-orm";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace enqueueMuxerJob function**
|
||||
|
||||
Replace raw SQL with:
|
||||
```typescript
|
||||
export async function enqueueMuxerJob(data: MuxerJobData): Promise<string> {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const jobId = `${data.userId}-${data.sessionId}`;
|
||||
const now = Date.now();
|
||||
|
||||
await db.insert(muxerJobs).values({
|
||||
id: jobId,
|
||||
data: JSON.stringify(data),
|
||||
status: "pending",
|
||||
attempts: 0,
|
||||
maxAttempts: 3,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).onConflictDoNothing();
|
||||
|
||||
logger.info({ jobId, userId: data.userId }, "Muxer job enqueued");
|
||||
return jobId;
|
||||
} catch (error) {
|
||||
logger.error({ error: error instanceof Error ? error.message : String(error) }, "Failed to enqueue muxer job");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace getPendingJobs function**
|
||||
|
||||
```typescript
|
||||
export async function getPendingJobs(): Promise<StoredJob[]> {
|
||||
const db = getDatabase();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(muxerJobs)
|
||||
.where(eq(muxerJobs.status, "pending"))
|
||||
.orderBy(asc(muxerJobs.createdAt))
|
||||
.limit(10);
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
status: row.status as "pending" | "processing" | "completed" | "failed",
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace updateJobStatus function**
|
||||
|
||||
```typescript
|
||||
export async function updateJobStatus(
|
||||
jobId: string,
|
||||
status: "processing" | "completed" | "failed",
|
||||
error?: string,
|
||||
): Promise<void> {
|
||||
const db = getDatabase();
|
||||
const now = Date.now();
|
||||
|
||||
if (status === "failed") {
|
||||
await db
|
||||
.update(muxerJobs)
|
||||
.set({
|
||||
status,
|
||||
attempts: muxerJobs.attempts + 1,
|
||||
updatedAt: now,
|
||||
error: error || null,
|
||||
})
|
||||
.where(eq(muxerJobs.id, jobId));
|
||||
} else {
|
||||
await db
|
||||
.update(muxerJobs)
|
||||
.set({ status, updatedAt: now })
|
||||
.where(eq(muxerJobs.id, jobId));
|
||||
}
|
||||
|
||||
logger.info({ jobId, status, error }, "Job status updated");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Replace remaining functions similarly**
|
||||
|
||||
Replace `retryFailedJob`, `cleanupCompletedJobs`, `getJobStats` with Drizzle equivalents
|
||||
|
||||
- [ ] **Step 6: Update getPersistedValue and setPersistedValue**
|
||||
|
||||
Use Drizzle's uiState table instead of raw SQL
|
||||
|
||||
- [ ] **Step 7: Run tests**
|
||||
|
||||
```bash
|
||||
cd /mnt/code/bete && pnpm run test
|
||||
```
|
||||
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/muxer-queue.ts
|
||||
git commit -m "refactor: migrate muxer-queue to drizzle-orm"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Migrate messageStore.ts to Drizzle
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/moderation/messageStore.ts`
|
||||
|
||||
- [ ] **Step 1: Replace imports**
|
||||
|
||||
```typescript
|
||||
import { getDatabase } from "../database/drizzle";
|
||||
import { messages, attachments } from "../database/schema";
|
||||
import { eq, or, desc, and } from "drizzle-orm";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace insertMessage function**
|
||||
|
||||
```typescript
|
||||
export async function insertMessage(message: MessageRecord): Promise<void> {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
await db.insert(messages).values(message).onConflictDoNothing();
|
||||
logger.debug({ messageId: message.id }, "Message inserted");
|
||||
} catch (error) {
|
||||
logger.error({ messageId: message.id, error: error instanceof Error ? error.message : String(error) }, "Failed to insert message");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace updateMessageAsEdited function**
|
||||
|
||||
```typescript
|
||||
export async function updateMessageAsEdited(
|
||||
messageId: string,
|
||||
editedContent: string,
|
||||
editedAt: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
await db
|
||||
.update(messages)
|
||||
.set({ edited_content: editedContent, edited_at: editedAt, type: "edited" })
|
||||
.where(eq(messages.id, messageId));
|
||||
logger.debug({ messageId }, "Message marked as edited");
|
||||
} catch (error) {
|
||||
logger.error({ messageId, error: error instanceof Error ? error.message : String(error) }, "Failed to update message as edited");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace getMessagesByChannel function**
|
||||
|
||||
```typescript
|
||||
export async function getMessagesByChannel(
|
||||
channelId: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
): Promise<MessageRecord[]> {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
return await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(or(eq(messages.channel_id, channelId), eq(messages.thread_id, channelId)))
|
||||
.orderBy(desc(messages.created_at))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
} catch (error) {
|
||||
logger.error({ channelId, error: error instanceof Error ? error.message : String(error) }, "Failed to get messages by channel");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Replace attachment functions similarly**
|
||||
|
||||
Replace `insertAttachment`, `getAttachmentsByChannel`, `updateAttachmentAsUploaded`, `updateAttachmentAsFailedUpload` with Drizzle equivalents
|
||||
|
||||
- [ ] **Step 6: Replace AI analysis functions**
|
||||
|
||||
Replace `updateMessageAIAnalysis`, `getPendingAIAnalysisMessages`, `getMessageById` with Drizzle equivalents
|
||||
|
||||
- [ ] **Step 7: Update function signatures**
|
||||
|
||||
Remove `db: DatabaseAdapter` parameter from all functions since they now use `getDatabase()` internally
|
||||
|
||||
- [ ] **Step 8: Run tests**
|
||||
|
||||
```bash
|
||||
cd /mnt/code/bete && pnpm run test
|
||||
```
|
||||
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/moderation/messageStore.ts
|
||||
git commit -m "refactor: migrate messageStore to drizzle-orm"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Update Application Initialization
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/index.ts`
|
||||
- Modify: `src/webserver.ts`
|
||||
|
||||
- [ ] **Step 1: Update src/index.ts imports**
|
||||
|
||||
Replace:
|
||||
```typescript
|
||||
import { getDatabase } from "./database/adapter";
|
||||
```
|
||||
|
||||
With:
|
||||
```typescript
|
||||
import { initializeDatabase } from "./database/drizzle";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update database initialization in index.ts**
|
||||
|
||||
```typescript
|
||||
const db = await initializeDatabase();
|
||||
logger.info({ type: config.DATABASE_TYPE }, "Database initialized");
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update src/webserver.ts**
|
||||
|
||||
Replace any `getDatabase()` calls with the new Drizzle client
|
||||
|
||||
- [ ] **Step 4: Run typecheck**
|
||||
|
||||
```bash
|
||||
cd /mnt/code/bete && pnpm run typecheck
|
||||
```
|
||||
|
||||
Expected: No TypeScript errors
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/index.ts src/webserver.ts
|
||||
git commit -m "feat: update application initialization for drizzle"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Remove Old Database Files
|
||||
|
||||
**Files:**
|
||||
- Delete: `src/database/adapter.ts`
|
||||
- Delete: `src/database/postgres.ts`
|
||||
- Delete: `src/database/migrations.ts`
|
||||
|
||||
- [ ] **Step 1: Remove old adapter files**
|
||||
|
||||
```bash
|
||||
cd /mnt/code/bete && rm src/database/adapter.ts src/database/postgres.ts src/database/migrations.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify no imports remain**
|
||||
|
||||
```bash
|
||||
grep -r "database/adapter\|database/postgres\|database/migrations" src/ --include="*.ts"
|
||||
```
|
||||
|
||||
Expected: No results
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor: remove old database adapter files"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Final Testing and Verification
|
||||
|
||||
**Files:**
|
||||
- Test all functionality
|
||||
|
||||
- [ ] **Step 1: Run full test suite**
|
||||
|
||||
```bash
|
||||
cd /mnt/code/bete && pnpm run test
|
||||
```
|
||||
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 2: Type check**
|
||||
|
||||
```bash
|
||||
cd /mnt/code/bete && pnpm run typecheck
|
||||
```
|
||||
|
||||
Expected: No TypeScript errors
|
||||
|
||||
- [ ] **Step 3: Lint**
|
||||
|
||||
```bash
|
||||
cd /mnt/code/bete && pnpm run lint
|
||||
```
|
||||
|
||||
Expected: No linting errors
|
||||
|
||||
- [ ] **Step 4: Test startup with SQLite**
|
||||
|
||||
```bash
|
||||
cd /mnt/code/bete && timeout 10 pnpm run dev || true
|
||||
```
|
||||
|
||||
Expected: Bot starts successfully, logs show "Database initialized"
|
||||
|
||||
- [ ] **Step 5: Verify git status**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
Expected: Clean working tree
|
||||
|
||||
- [ ] **Step 6: Final commit if needed**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: complete drizzle-orm migration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec Coverage Checklist
|
||||
|
||||
- ✅ Replace raw SQL with Drizzle ORM
|
||||
- ✅ Type-safe database operations
|
||||
- ✅ Support both SQLite and PostgreSQL
|
||||
- ✅ Automatic schema migrations
|
||||
- ✅ All existing functionality preserved
|
||||
- ✅ Backward compatible with existing data
|
||||
- ✅ Cleaner, more maintainable code
|
||||
- ✅ Better error handling
|
||||
- ✅ Tests passing
|
||||
- ✅ No TypeScript errors
|
||||
|
||||
---
|
||||
|
||||
Plan complete and saved to `/mnt/code/bete/docs/superpowers/plans/2026-05-14-drizzle-orm-migration.md`.
|
||||
|
||||
**Two execution options:**
|
||||
|
||||
**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
|
||||
|
||||
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
|
||||
|
||||
Which approach would you prefer?
|
||||
183
docs/superpowers/plans/2026-05-14-library-modernization.md
Normal file
183
docs/superpowers/plans/2026-05-14-library-modernization.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Library Modernization 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:** Modernize runtime and development dependencies while preserving Discord monitoring, recording, database migration, dashboard, and test behavior.
|
||||
|
||||
**Architecture:** Treat modernization as dependency classification plus small source refactors. Remove redundant validation libraries by moving `src/validation.ts` to Zod, replace `fluent-ffmpeg` with a tiny direct `ffmpeg` process wrapper for the muxer scripts, and convert database migration code to ESM-safe imports. Keep high-risk Discord/audio/native packages unless audit proves a safe replacement exists.
|
||||
|
||||
**Tech Stack:** Node.js, pnpm, TypeScript, Zod, Drizzle ORM, better-sqlite3, pg, Express, ws, Vite, React, Vitest, Biome, Discord voice/audio packages.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Audit Baseline
|
||||
|
||||
- Usage audit confirms `class-transformer`/`class-validator` are only used by `src/validation.ts`.
|
||||
- Usage audit confirms `fluent-ffmpeg` is only used by `src/muxer.ts` and `src/muxer-aup3.ts`.
|
||||
- `pnpm outdated --format table` reports `discord.js-selfbot-v13` and `fluent-ffmpeg` as deprecated.
|
||||
- Outdated packages reported: `tsx`, `@types/node`, `p-retry`, `pino`, `pino-pretty`, `sodium-native`.
|
||||
- Direct dependency classification: remove `class-transformer`, `class-validator`, `fluent-ffmpeg`, `@types/fluent-ffmpeg`; replace validation with Zod and ffmpeg wrapper with `node:child_process`; upgrade outdated packages; keep high-risk voice/audio packages unless a compatible replacement is proven.
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `package.json`: dependency upgrades, removals, and script additions if needed.
|
||||
- Modify `pnpm-lock.yaml`: regenerated by `pnpm install`.
|
||||
- Modify `src/validation.ts`: replace `class-transformer` and `class-validator` with Zod.
|
||||
- Modify `src/database/migrate.ts`: remove dynamic CommonJS `require` and `any` cast.
|
||||
- Create `src/audio/ffmpegProcess.ts`: small wrapper around `node:child_process` for direct ffmpeg execution.
|
||||
- Modify `src/muxer.ts`: use `runFfmpeg()` instead of `fluent-ffmpeg`.
|
||||
- Modify `src/muxer-aup3.ts`: use `runFfmpeg()` instead of `fluent-ffmpeg`.
|
||||
- Modify `src/recorder/decoder.ts`: keep `createRequire()` for optional native probing unless a better ESM-safe probe is identified during implementation.
|
||||
- Add or modify tests under `tests/`: validation, migration helper behavior, and ffmpeg argument construction.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Capture Dependency Audit Baseline
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/plans/2026-05-14-library-modernization.md`
|
||||
- Inspect: `package.json`
|
||||
- Inspect: `pnpm-lock.yaml`
|
||||
- Inspect: `src/**/*.ts`
|
||||
- Inspect: `tests/**/*.ts`
|
||||
|
||||
- [x] **Step 1: List direct dependency usage**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -R "class-transformer\|class-validator\|fluent-ffmpeg\|@discordjs/opus\|@discordjs/voice\|@snazzah/davey\|discord.js-selfbot-v13\|libsodium-wrappers\|sodium-native\|prism-media\|drizzle-orm\|better-sqlite3\|pg\|express\|helmet\|p-retry\|pino\|pino-http\|prom-client\|react\|react-dom\|vite\|ws\|zod" -n src tests frontend package.json
|
||||
```
|
||||
|
||||
Expected: output lists every direct package usage. Record the summary in the implementation notes during execution.
|
||||
|
||||
- [x] **Step 2: Check outdated dependencies**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm outdated --format table
|
||||
```
|
||||
|
||||
Expected: command exits non-zero if packages are outdated; use the table as audit input, not as failure.
|
||||
|
||||
- [x] **Step 3: Classify direct dependencies**
|
||||
|
||||
Use this classification as the starting point, adjusting only if Step 1 proves a package is unused or irreplaceable:
|
||||
|
||||
```text
|
||||
remove: class-transformer, class-validator, fluent-ffmpeg, @types/fluent-ffmpeg
|
||||
replace: class-transformer/class-validator -> zod; fluent-ffmpeg -> node:child_process ffmpeg wrapper
|
||||
upgrade: @vitejs/plugin-react, better-sqlite3, discord.js-selfbot-v13, dotenv, drizzle-orm, express, helmet, libsodium-wrappers, p-retry, pg, pino, pino-http, prom-client, react, react-dom, sodium-native, vite, ws, zod, @biomejs/biome, @types/*, drizzle-kit, pino-pretty, tsx, vitest
|
||||
keep unless compatible alternative is proven: @discordjs/opus, @discordjs/voice, @snazzah/davey, prism-media
|
||||
```
|
||||
|
||||
- [x] **Step 4: Commit audit note if this task changes files**
|
||||
|
||||
If only commands were run, do not commit. If the plan is updated with audit notes, run:
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/plans/2026-05-14-library-modernization.md
|
||||
git commit -m "docs: record dependency modernization audit"
|
||||
```
|
||||
|
||||
Expected: commit succeeds only if a file changed.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Replace Class Validator Stack With Zod
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/validation.ts`
|
||||
- Test: `tests/validation.test.ts`
|
||||
- Modify later: `package.json`
|
||||
|
||||
- [ ] **Step 1: Write failing validation tests**
|
||||
- [ ] **Step 2: Run validation tests to establish baseline**
|
||||
- [ ] **Step 3: Replace implementation with Zod**
|
||||
- [ ] **Step 4: Run validation tests**
|
||||
- [ ] **Step 5: Commit validation refactor**
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Convert Migration Code to ESM-Safe Drizzle Imports
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/database/migrate.ts`
|
||||
- Test: `tests/database/migrate.test.ts`
|
||||
|
||||
- [ ] **Step 1: Extract SQLite database creation for testing**
|
||||
- [ ] **Step 2: Add migration helper test**
|
||||
- [ ] **Step 3: Run migration test**
|
||||
- [ ] **Step 4: Run typecheck for migration typing**
|
||||
- [ ] **Step 5: Commit migration refactor**
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Replace Fluent FFmpeg With Direct Process Wrapper
|
||||
|
||||
**Files:**
|
||||
- Create: `src/audio/ffmpegProcess.ts`
|
||||
- Modify: `src/muxer.ts`
|
||||
- Modify: `src/muxer-aup3.ts`
|
||||
- Test: `tests/audio/ffmpegProcess.test.ts`
|
||||
|
||||
- [ ] **Step 1: Add ffmpeg wrapper tests**
|
||||
- [ ] **Step 2: Run ffmpeg wrapper test to verify it fails**
|
||||
- [ ] **Step 3: Implement ffmpeg process wrapper**
|
||||
- [ ] **Step 4: Refactor `src/muxer.ts`**
|
||||
- [ ] **Step 5: Refactor `src/muxer-aup3.ts`**
|
||||
- [ ] **Step 6: Run ffmpeg wrapper tests**
|
||||
- [ ] **Step 7: Run typecheck**
|
||||
- [ ] **Step 8: Commit ffmpeg refactor**
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update Package Manifest and Lockfile
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
- Modify: `pnpm-lock.yaml`
|
||||
|
||||
- [ ] **Step 1: Remove replaced packages**
|
||||
- [ ] **Step 2: Upgrade dependencies interactively-free**
|
||||
- [ ] **Step 3: Ensure package manager remains pnpm 10**
|
||||
- [ ] **Step 4: Run install to verify lockfile**
|
||||
- [ ] **Step 5: Commit dependency manifest changes**
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Fix Upgrade Breakages
|
||||
|
||||
**Files:**
|
||||
- Modify as needed: `src/**/*.ts`
|
||||
- Modify as needed: `frontend/**/*.ts`
|
||||
- Modify as needed: `frontend/**/*.tsx`
|
||||
- Modify as needed: `tests/**/*.ts`
|
||||
- Modify as needed: config files touched by upgraded tools
|
||||
|
||||
- [ ] **Step 1: Run typecheck**
|
||||
- [ ] **Step 2: Run lint**
|
||||
- [ ] **Step 3: Run tests**
|
||||
- [ ] **Step 4: Run build**
|
||||
- [ ] **Step 5: Commit breakage fixes**
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Final Verification and Manual Dashboard Check
|
||||
|
||||
**Files:**
|
||||
- No planned source changes
|
||||
|
||||
- [ ] **Step 1: Run full verification**
|
||||
- [ ] **Step 2: Start dev server for dashboard check**
|
||||
- [ ] **Step 3: Manually verify frontend build path if browser access is available**
|
||||
- [ ] **Step 4: Check git status**
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: audit, dependency classification, replacement/removal, ESM migration, lockfile regeneration, verification, and dashboard manual check are covered.
|
||||
- Placeholder scan: no `TBD`, `TODO`, or unspecified implementation steps remain.
|
||||
- Type consistency: helper names are consistent across tasks: `validateUserStateUpdate`, `initializeMigrationSqliteDatabase`, `buildMuxFfmpegArgs`, and `runFfmpeg`.
|
||||
189
docs/superpowers/plans/2026-05-15-discord-video-stream-vendor.md
Normal file
189
docs/superpowers/plans/2026-05-15-discord-video-stream-vendor.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Discord Video Stream Vendor 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:** Add `@dank074/discord-video-stream` as a vendored workspace dependency backed by the SSH submodule remote `ssh://git@43.134.105.109:22222/exceed/Discord-video-stream.git`.
|
||||
|
||||
**Architecture:** Follow the existing `discord.js-selfbot-v13` pattern: keep third-party source under `vendor/`, track it as a git submodule, include it in `pnpm-workspace.yaml`, and consume it from the root app with `workspace:*`. Clone from the public GitHub repository for source availability, then set `.gitmodules` to the requested SSH mirror URL so future submodule operations use the private remote.
|
||||
|
||||
**Tech Stack:** Git submodules, pnpm workspaces, Node.js package metadata, TypeScript project verification.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `.gitmodules`: add `vendor/Discord-video-stream` submodule entry with SSH URL.
|
||||
- Create submodule path `vendor/Discord-video-stream`: checkout public upstream `https://github.com/Discord-RE/Discord-video-stream.git` at current `master` HEAD.
|
||||
- Modify `pnpm-workspace.yaml`: add `vendor/Discord-video-stream` to workspace packages.
|
||||
- Modify `package.json`: add root dependency `"@dank074/discord-video-stream": "workspace:*"`.
|
||||
- Modify `pnpm-lock.yaml`: update lockfile after `pnpm install`.
|
||||
|
||||
## Task 1: Add Vendor Submodule
|
||||
|
||||
**Files:**
|
||||
- Modify: `.gitmodules`
|
||||
- Create: `vendor/Discord-video-stream`
|
||||
|
||||
- [ ] **Step 1: Verify vendor path does not already exist**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
test ! -e vendor/Discord-video-stream
|
||||
```
|
||||
|
||||
Expected: exit code `0`. If it exists, stop and inspect it with `git status --short vendor/Discord-video-stream` before proceeding.
|
||||
|
||||
- [ ] **Step 2: Add the submodule from public source**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git submodule add https://github.com/Discord-RE/Discord-video-stream.git vendor/Discord-video-stream
|
||||
```
|
||||
|
||||
Expected: Git creates `vendor/Discord-video-stream` and updates `.gitmodules`.
|
||||
|
||||
- [ ] **Step 3: Set submodule URL to requested SSH mirror**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git config -f .gitmodules submodule.vendor/Discord-video-stream.url ssh://git@43.134.105.109:22222/exceed/Discord-video-stream.git
|
||||
git submodule sync vendor/Discord-video-stream
|
||||
```
|
||||
|
||||
Expected: `.gitmodules` contains:
|
||||
|
||||
```ini
|
||||
[submodule "vendor/Discord-video-stream"]
|
||||
path = vendor/Discord-video-stream
|
||||
url = ssh://git@43.134.105.109:22222/exceed/Discord-video-stream.git
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify package identity**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node -e "const p=require('./vendor/Discord-video-stream/package.json'); console.log(p.name)"
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```text
|
||||
@dank074/discord-video-stream
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit submodule metadata only when requested**
|
||||
|
||||
Do not commit unless the user explicitly asks. This session's user has asked to implement but has not asked for a commit for this task.
|
||||
|
||||
## Task 2: Wire pnpm Workspace Dependency
|
||||
|
||||
**Files:**
|
||||
- Modify: `pnpm-workspace.yaml`
|
||||
- Modify: `package.json`
|
||||
- Modify: `pnpm-lock.yaml`
|
||||
|
||||
- [ ] **Step 1: Add workspace package path**
|
||||
|
||||
Modify `pnpm-workspace.yaml` to exactly:
|
||||
|
||||
```yaml
|
||||
packages:
|
||||
- .
|
||||
- vendor/discord.js-selfbot-v13
|
||||
- vendor/Discord-video-stream
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@discordjs/opus'
|
||||
- better-sqlite3
|
||||
- esbuild
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add root dependency**
|
||||
|
||||
In `package.json`, add dependency under `dependencies`:
|
||||
|
||||
```json
|
||||
"@dank074/discord-video-stream": "workspace:*"
|
||||
```
|
||||
|
||||
Keep alphabetical-ish placement with scoped packages near the top, for example after `"@discordjs/voice"`.
|
||||
|
||||
- [ ] **Step 3: Install and update lockfile**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Expected: `pnpm-lock.yaml` updates and root dependency resolves to `link:vendor/Discord-video-stream`.
|
||||
|
||||
- [ ] **Step 4: Verify workspace resolution**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm list @dank074/discord-video-stream --depth 0
|
||||
```
|
||||
|
||||
Expected output includes:
|
||||
|
||||
```text
|
||||
@dank074/discord-video-stream link:vendor/Discord-video-stream
|
||||
```
|
||||
|
||||
## Task 3: Verify Project Health
|
||||
|
||||
**Files:**
|
||||
- No new files unless fixes are required.
|
||||
|
||||
- [ ] **Step 1: Run typecheck**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm run test
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run build**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Inspect final status**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git submodule status
|
||||
```
|
||||
|
||||
Expected: root status shows `.gitmodules`, `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, and `vendor/Discord-video-stream` as changed/added. Submodule status includes `vendor/Discord-video-stream` at the checked-out commit.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: submodule creation is Task 1; workspace dependency wiring is Task 2; verification is Task 3.
|
||||
- Placeholder scan: no TBD/TODO/fill-in steps remain.
|
||||
- Type consistency: package name `@dank074/discord-video-stream`, path `vendor/Discord-video-stream`, and SSH URL are consistent across all tasks.
|
||||
1291
docs/superpowers/plans/2026-05-15-media-music-phase-1.md
Normal file
1291
docs/superpowers/plans/2026-05-15-media-music-phase-1.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,713 @@
|
||||
# Media YouTube and Spotify Resolver Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Extend media playback input so users can queue YouTube URLs, plain search queries, and Spotify track URLs that resolve to playable YouTube audio.
|
||||
|
||||
**Architecture:** Keep playback unchanged: `musicPlayer` still passes one resolved source to ffmpeg. Add resolver units that turn rich inputs into direct playable URLs before queueing: `play-dl` for YouTube search and Spotify metadata, `yt-dlp` wrapper for YouTube metadata/direct URL extraction when available. Spotify track support resolves metadata then searches YouTube; no Spotify playlist/album support in this phase.
|
||||
|
||||
**Tech Stack:** TypeScript, Vitest, Node `child_process`, `play-dl`, external `yt-dlp` command when installed, existing Express/media controller/music player.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `package.json` and `pnpm-lock.yaml` — add `play-dl` dependency.
|
||||
- Modify `src/media/mediaTypes.ts` — extend `MediaSourceKind` with `youtube`, `spotify`, and `search`.
|
||||
- Create `src/media/ytdlp.ts` — small wrapper around external `yt-dlp` for JSON metadata and direct audio URL extraction.
|
||||
- Create `src/media/playDlResolver.ts` — wrapper around `play-dl` for YouTube search and Spotify track metadata.
|
||||
- Modify `src/media/mediaResolver.ts` — compose local/direct URL/YouTube/search/Spotify resolution.
|
||||
- Modify `public/index.html` — update input label/placeholder to mention YouTube, Spotify track, and search.
|
||||
- Tests:
|
||||
- `tests/media/ytdlp.test.ts`
|
||||
- `tests/media/playDlResolver.test.ts`
|
||||
- `tests/media/mediaResolver.test.ts`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add play-dl and Media Source Kinds
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
- Modify: `pnpm-lock.yaml`
|
||||
- Modify: `src/media/mediaTypes.ts`
|
||||
- Test: `tests/media/mediaResolver.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing type expectation in resolver test**
|
||||
|
||||
Append to `tests/media/mediaResolver.test.ts`:
|
||||
|
||||
```ts
|
||||
it("keeps direct URLs as generic URL sources", async () => {
|
||||
await expect(
|
||||
resolveMediaSource("https://cdn.example.com/song.mp3"),
|
||||
).resolves.toMatchObject({
|
||||
kind: "url",
|
||||
source: "https://cdn.example.com/song.mp3",
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
This test should already pass before type changes; it protects existing behavior.
|
||||
|
||||
- [ ] **Step 2: Install play-dl**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm -C /mnt/code/bete add play-dl
|
||||
```
|
||||
|
||||
Expected: `package.json` contains `"play-dl"` in dependencies and `pnpm-lock.yaml` updates.
|
||||
|
||||
- [ ] **Step 3: Extend media source kinds**
|
||||
|
||||
Modify `src/media/mediaTypes.ts`:
|
||||
|
||||
```ts
|
||||
export type MediaSourceKind = "url" | "local" | "youtube" | "spotify" | "search";
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run protected resolver test and typecheck**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm -C /mnt/code/bete exec vitest run tests/media/mediaResolver.test.ts
|
||||
pnpm -C /mnt/code/bete run typecheck
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit task 1**
|
||||
|
||||
```bash
|
||||
git -C /mnt/code/bete add package.json pnpm-lock.yaml src/media/mediaTypes.ts tests/media/mediaResolver.test.ts
|
||||
git -C /mnt/code/bete commit -m "feat: prepare media resolver source kinds"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: yt-dlp Wrapper
|
||||
|
||||
**Files:**
|
||||
- Create: `src/media/ytdlp.ts`
|
||||
- Test: `tests/media/ytdlp.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing yt-dlp tests**
|
||||
|
||||
Create `tests/media/ytdlp.test.ts`:
|
||||
|
||||
```ts
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createYtDlp } from "../../src/media/ytdlp";
|
||||
|
||||
class FakeProcess extends EventEmitter {
|
||||
stdout = new PassThrough();
|
||||
stderr = new PassThrough();
|
||||
}
|
||||
|
||||
describe("createYtDlp", () => {
|
||||
it("reads YouTube metadata as JSON", async () => {
|
||||
const proc = new FakeProcess();
|
||||
const spawn = vi.fn(() => proc);
|
||||
const ytdlp = createYtDlp({ spawn });
|
||||
|
||||
const result = ytdlp.getMetadata("https://youtu.be/video");
|
||||
proc.stdout.write(JSON.stringify({ title: "Song Title", webpage_url: "https://youtube.com/watch?v=video" }));
|
||||
proc.stdout.end();
|
||||
proc.emit("close", 0);
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
title: "Song Title",
|
||||
webpageUrl: "https://youtube.com/watch?v=video",
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledWith("yt-dlp", [
|
||||
"https://youtu.be/video",
|
||||
"--dump-single-json",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
});
|
||||
|
||||
it("reads direct audio URL", async () => {
|
||||
const proc = new FakeProcess();
|
||||
const ytdlp = createYtDlp({ spawn: vi.fn(() => proc) });
|
||||
|
||||
const result = ytdlp.getDirectAudioUrl("https://youtu.be/video");
|
||||
proc.stdout.write("https://audio.example.com/stream\n");
|
||||
proc.stdout.end();
|
||||
proc.emit("close", 0);
|
||||
|
||||
await expect(result).resolves.toBe("https://audio.example.com/stream");
|
||||
});
|
||||
|
||||
it("rejects when yt-dlp exits non-zero", async () => {
|
||||
const proc = new FakeProcess();
|
||||
const ytdlp = createYtDlp({ spawn: vi.fn(() => proc) });
|
||||
|
||||
const result = ytdlp.getMetadata("https://youtu.be/video");
|
||||
proc.stderr.write("failed");
|
||||
proc.stderr.end();
|
||||
proc.emit("close", 1);
|
||||
|
||||
await expect(result).rejects.toThrow("yt-dlp failed with code 1");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
pnpm -C /mnt/code/bete exec vitest run tests/media/ytdlp.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because `src/media/ytdlp.ts` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement yt-dlp wrapper**
|
||||
|
||||
Create `src/media/ytdlp.ts`:
|
||||
|
||||
```ts
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { spawn as nodeSpawn } from "node:child_process";
|
||||
|
||||
export interface YtDlpMetadata {
|
||||
title: string;
|
||||
webpageUrl: string;
|
||||
}
|
||||
|
||||
export interface YtDlpClient {
|
||||
getMetadata(url: string): Promise<YtDlpMetadata>;
|
||||
getDirectAudioUrl(url: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface YtDlpDependencies {
|
||||
spawn?: typeof nodeSpawn;
|
||||
}
|
||||
|
||||
export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient {
|
||||
const spawn = dependencies.spawn ?? nodeSpawn;
|
||||
|
||||
return {
|
||||
async getMetadata(url: string): Promise<YtDlpMetadata> {
|
||||
const data = await runYtDlp(spawn, [
|
||||
url,
|
||||
"--dump-single-json",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
]);
|
||||
const parsed = JSON.parse(data) as { title?: string; webpage_url?: string };
|
||||
return {
|
||||
title: parsed.title || url,
|
||||
webpageUrl: parsed.webpage_url || url,
|
||||
};
|
||||
},
|
||||
|
||||
async getDirectAudioUrl(url: string): Promise<string> {
|
||||
return runYtDlp(spawn, [
|
||||
url,
|
||||
"--get-url",
|
||||
"--format",
|
||||
"bestaudio[protocol^=http]/bestaudio/best",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
]).then((value) => value.trim().split("\n")[0] || url);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runYtDlp(
|
||||
spawn: typeof nodeSpawn,
|
||||
args: string[],
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("yt-dlp", args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}) as unknown as ChildProcessWithoutNullStreams;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
proc.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
proc.on("error", reject);
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
return;
|
||||
}
|
||||
reject(new Error(`yt-dlp failed with code ${code}: ${stderr.trim()}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run yt-dlp tests and typecheck**
|
||||
|
||||
```bash
|
||||
pnpm -C /mnt/code/bete exec vitest run tests/media/ytdlp.test.ts
|
||||
pnpm -C /mnt/code/bete run typecheck
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit task 2**
|
||||
|
||||
```bash
|
||||
git -C /mnt/code/bete add src/media/ytdlp.ts tests/media/ytdlp.test.ts
|
||||
git -C /mnt/code/bete commit -m "feat: add yt-dlp media helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: play-dl Resolver Wrapper
|
||||
|
||||
**Files:**
|
||||
- Create: `src/media/playDlResolver.ts`
|
||||
- Test: `tests/media/playDlResolver.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing play-dl resolver tests**
|
||||
|
||||
Create `tests/media/playDlResolver.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createPlayDlResolver } from "../../src/media/playDlResolver";
|
||||
|
||||
describe("createPlayDlResolver", () => {
|
||||
it("returns the first YouTube search result", async () => {
|
||||
const resolver = createPlayDlResolver({
|
||||
search: vi.fn(async () => [
|
||||
{ title: "Song Result", url: "https://youtube.com/watch?v=abc" },
|
||||
]),
|
||||
spotify: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(resolver.searchYouTube("artist song")).resolves.toEqual({
|
||||
title: "Song Result",
|
||||
url: "https://youtube.com/watch?v=abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("turns Spotify track metadata into a YouTube search query", async () => {
|
||||
const resolver = createPlayDlResolver({
|
||||
search: vi.fn(async () => [
|
||||
{ title: "Artist - Track", url: "https://youtube.com/watch?v=track" },
|
||||
]),
|
||||
spotify: vi.fn(async () => ({
|
||||
type: "track",
|
||||
name: "Track",
|
||||
artists: [{ name: "Artist" }],
|
||||
})),
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolver.resolveSpotifyTrack("https://open.spotify.com/track/123"),
|
||||
).resolves.toEqual({
|
||||
title: "Artist - Track",
|
||||
url: "https://youtube.com/watch?v=track",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects Spotify playlists in this phase", async () => {
|
||||
const resolver = createPlayDlResolver({
|
||||
search: vi.fn(),
|
||||
spotify: vi.fn(async () => ({ type: "playlist", name: "Playlist" })),
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolver.resolveSpotifyTrack("https://open.spotify.com/playlist/123"),
|
||||
).rejects.toThrow("Only Spotify track URLs are supported");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
pnpm -C /mnt/code/bete exec vitest run tests/media/playDlResolver.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because `src/media/playDlResolver.ts` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement play-dl wrapper**
|
||||
|
||||
Create `src/media/playDlResolver.ts`:
|
||||
|
||||
```ts
|
||||
import play from "play-dl";
|
||||
|
||||
export interface PlayDlResult {
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface PlayDlSearchResult {
|
||||
title?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface SpotifyTrackLike {
|
||||
type?: string;
|
||||
name?: string;
|
||||
artists?: Array<{ name?: string }>;
|
||||
}
|
||||
|
||||
export interface PlayDlDependencies {
|
||||
search?: (query: string, options: { limit: number }) => Promise<PlayDlSearchResult[]>;
|
||||
spotify?: (url: string) => Promise<SpotifyTrackLike>;
|
||||
}
|
||||
|
||||
export function createPlayDlResolver(dependencies: PlayDlDependencies = {}) {
|
||||
const search = dependencies.search ?? play.search;
|
||||
const spotify = dependencies.spotify ?? play.spotify;
|
||||
|
||||
return {
|
||||
async searchYouTube(query: string): Promise<PlayDlResult> {
|
||||
const results = await search(query, { limit: 1 });
|
||||
const first = results[0];
|
||||
if (!first?.url) throw new Error(`No YouTube result found for ${query}`);
|
||||
return {
|
||||
title: first.title || query,
|
||||
url: first.url,
|
||||
};
|
||||
},
|
||||
|
||||
async resolveSpotifyTrack(url: string): Promise<PlayDlResult> {
|
||||
const track = await spotify(url);
|
||||
if (track.type !== "track") {
|
||||
throw new Error("Only Spotify track URLs are supported");
|
||||
}
|
||||
const artists = (track.artists || [])
|
||||
.map((artist) => artist.name)
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const query = `${artists} ${track.name || ""} audio`.trim();
|
||||
return this.searchYouTube(query);
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run play-dl tests and typecheck**
|
||||
|
||||
```bash
|
||||
pnpm -C /mnt/code/bete exec vitest run tests/media/playDlResolver.test.ts
|
||||
pnpm -C /mnt/code/bete run typecheck
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit task 3**
|
||||
|
||||
```bash
|
||||
git -C /mnt/code/bete add src/media/playDlResolver.ts tests/media/playDlResolver.test.ts
|
||||
git -C /mnt/code/bete commit -m "feat: add play-dl search resolver"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Compose Resolver for YouTube, Search, and Spotify Track
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/media/mediaResolver.ts`
|
||||
- Test: `tests/media/mediaResolver.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing composed resolver tests**
|
||||
|
||||
Append to `tests/media/mediaResolver.test.ts`:
|
||||
|
||||
```ts
|
||||
import { createMediaResolver } from "../../src/media/mediaResolver";
|
||||
|
||||
// Add inside describe("resolveMediaSource", ...):
|
||||
it("resolves YouTube URLs with yt-dlp metadata", async () => {
|
||||
const resolver = createMediaResolver({
|
||||
ytdlp: {
|
||||
getMetadata: vi.fn(async () => ({
|
||||
title: "YouTube Song",
|
||||
webpageUrl: "https://youtube.com/watch?v=abc",
|
||||
})),
|
||||
getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/abc"),
|
||||
},
|
||||
playDlResolver: {
|
||||
searchYouTube: vi.fn(),
|
||||
resolveSpotifyTrack: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(resolver("https://youtu.be/abc")).resolves.toEqual({
|
||||
source: "https://audio.example.com/abc",
|
||||
title: "YouTube Song",
|
||||
kind: "youtube",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves search queries to YouTube results", async () => {
|
||||
const resolver = createMediaResolver({
|
||||
ytdlp: {
|
||||
getMetadata: vi.fn(),
|
||||
getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/search"),
|
||||
},
|
||||
playDlResolver: {
|
||||
searchYouTube: vi.fn(async () => ({
|
||||
title: "Search Result",
|
||||
url: "https://youtube.com/watch?v=search",
|
||||
})),
|
||||
resolveSpotifyTrack: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(resolver("artist song")).resolves.toEqual({
|
||||
source: "https://audio.example.com/search",
|
||||
title: "Search Result",
|
||||
kind: "search",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves Spotify track URLs through YouTube search", async () => {
|
||||
const resolver = createMediaResolver({
|
||||
ytdlp: {
|
||||
getMetadata: vi.fn(),
|
||||
getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/spotify"),
|
||||
},
|
||||
playDlResolver: {
|
||||
searchYouTube: vi.fn(),
|
||||
resolveSpotifyTrack: vi.fn(async () => ({
|
||||
title: "Spotify Match",
|
||||
url: "https://youtube.com/watch?v=spotify",
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolver("https://open.spotify.com/track/123"),
|
||||
).resolves.toEqual({
|
||||
source: "https://audio.example.com/spotify",
|
||||
title: "Spotify Match",
|
||||
kind: "spotify",
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Also update imports at the top:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMediaResolver, resolveMediaSource } from "../../src/media/mediaResolver";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
pnpm -C /mnt/code/bete exec vitest run tests/media/mediaResolver.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because `createMediaResolver` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement composed resolver**
|
||||
|
||||
Modify `src/media/mediaResolver.ts` to export `createMediaResolver()` and keep `resolveMediaSource` as the default instance:
|
||||
|
||||
```ts
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { AppError } from "../errors";
|
||||
import { createPlayDlResolver } from "./playDlResolver";
|
||||
import type { ResolvedMediaSource } from "./mediaTypes";
|
||||
import { createYtDlp, type YtDlpClient } from "./ytdlp";
|
||||
|
||||
type PlayDlResolver = ReturnType<typeof createPlayDlResolver>;
|
||||
|
||||
export interface MediaResolverDependencies {
|
||||
ytdlp?: YtDlpClient;
|
||||
playDlResolver?: PlayDlResolver;
|
||||
}
|
||||
|
||||
export function createMediaResolver(
|
||||
dependencies: MediaResolverDependencies = {},
|
||||
) {
|
||||
const ytdlp = dependencies.ytdlp ?? createYtDlp();
|
||||
const playDlResolver = dependencies.playDlResolver ?? createPlayDlResolver();
|
||||
|
||||
return async function resolve(input: string): Promise<ResolvedMediaSource> {
|
||||
const source = input.trim();
|
||||
if (!source) {
|
||||
throw new AppError("Media source is required", "MISSING_MEDIA_SOURCE", 400);
|
||||
}
|
||||
|
||||
const url = parseUrl(source);
|
||||
if (url && isYouTubeUrl(url)) {
|
||||
const metadata = await ytdlp.getMetadata(source);
|
||||
const directUrl = await ytdlp.getDirectAudioUrl(source);
|
||||
return { source: directUrl, title: metadata.title, kind: "youtube" };
|
||||
}
|
||||
|
||||
if (url && isSpotifyTrackUrl(url)) {
|
||||
const result = await playDlResolver.resolveSpotifyTrack(source);
|
||||
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
|
||||
return { source: directUrl, title: result.title, kind: "spotify" };
|
||||
}
|
||||
|
||||
const urlSource = resolveUrlSource(source);
|
||||
if (urlSource) return urlSource;
|
||||
|
||||
const localPath = path.resolve(source);
|
||||
if (existsSync(localPath) && statSync(localPath).isFile()) {
|
||||
return {
|
||||
source: localPath,
|
||||
title: path.basename(localPath),
|
||||
kind: "local",
|
||||
};
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
const result = await playDlResolver.searchYouTube(source);
|
||||
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
|
||||
return { source: directUrl, title: result.title, kind: "search" };
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
"Media source must be an HTTP(S) URL, YouTube URL, Spotify track URL, search query, or existing local file",
|
||||
"UNSUPPORTED_MEDIA_SOURCE",
|
||||
400,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const resolveMediaSource = createMediaResolver();
|
||||
|
||||
function parseUrl(source: string): URL | null {
|
||||
try {
|
||||
return new URL(source);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isYouTubeUrl(url: URL): boolean {
|
||||
return ["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"].includes(
|
||||
url.hostname,
|
||||
);
|
||||
}
|
||||
|
||||
function isSpotifyTrackUrl(url: URL): boolean {
|
||||
return url.hostname === "open.spotify.com" && url.pathname.startsWith("/track/");
|
||||
}
|
||||
|
||||
function resolveUrlSource(source: string): ResolvedMediaSource | null {
|
||||
const url = parseUrl(source);
|
||||
if (!url) return null;
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
|
||||
return {
|
||||
source,
|
||||
title: titleFromUrl(url),
|
||||
kind: "url",
|
||||
};
|
||||
}
|
||||
|
||||
function titleFromUrl(url: URL): string {
|
||||
const filename = decodeURIComponent(url.pathname.split("/").pop() || "");
|
||||
return path.basename(filename) || url.hostname;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run resolver tests and typecheck**
|
||||
|
||||
```bash
|
||||
pnpm -C /mnt/code/bete exec vitest run tests/media/mediaResolver.test.ts
|
||||
pnpm -C /mnt/code/bete run typecheck
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit task 4**
|
||||
|
||||
```bash
|
||||
git -C /mnt/code/bete add src/media/mediaResolver.ts tests/media/mediaResolver.test.ts
|
||||
git -C /mnt/code/bete commit -m "feat: resolve youtube search and spotify media"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Dashboard Copy and Full Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/index.html`
|
||||
|
||||
- [ ] **Step 1: Update media input copy**
|
||||
|
||||
Change the media input label and placeholder in `public/index.html` from:
|
||||
|
||||
```html
|
||||
<label for="mediaSourceInput">Music URL / file path</label>
|
||||
<input id="mediaSourceInput" type="text" placeholder="https://example.com/song.mp3">
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```html
|
||||
<label for="mediaSourceInput">Music URL, YouTube, Spotify track, search, or file path</label>
|
||||
<input id="mediaSourceInput" type="text" placeholder="YouTube URL, Spotify track, or search terms">
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run full verification**
|
||||
|
||||
```bash
|
||||
pnpm -C /mnt/code/bete run test
|
||||
pnpm -C /mnt/code/bete run typecheck
|
||||
pnpm -C /mnt/code/bete run lint
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Manual verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm -C /mnt/code/bete run dev
|
||||
```
|
||||
|
||||
Manual checks:
|
||||
|
||||
1. Queue a direct MP3 URL: still plays.
|
||||
2. Queue a local file path: still plays.
|
||||
3. Queue a YouTube URL: resolves title and plays audio.
|
||||
4. Queue plain search terms: resolves first YouTube result and plays audio.
|
||||
5. Queue a Spotify track URL: resolves Spotify metadata, searches YouTube, and plays audio.
|
||||
6. Queue a Spotify playlist URL: returns a clear unsupported error.
|
||||
|
||||
- [ ] **Step 4: Commit task 5**
|
||||
|
||||
```bash
|
||||
git -C /mnt/code/bete add public/index.html
|
||||
git -C /mnt/code/bete commit -m "feat: update media input guidance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
Spec coverage:
|
||||
|
||||
- YouTube URL support: Task 2 + Task 4.
|
||||
- Search query support: Task 3 + Task 4.
|
||||
- Spotify track URL to YouTube support: Task 3 + Task 4.
|
||||
- No Spotify playlist/album support: Task 3 explicitly rejects non-track Spotify types, Task 5 manual check covers playlist error.
|
||||
- Dashboard copy: Task 5.
|
||||
- Existing direct URL/local file behavior protected: Task 1 + existing tests.
|
||||
|
||||
Placeholder scan: no placeholders, TODOs, or vague test instructions remain.
|
||||
|
||||
Type consistency: `MediaSourceKind` includes `youtube`, `spotify`, and `search`; resolver returns those exact values; tests assert those values.
|
||||
@@ -0,0 +1,663 @@
|
||||
# Selfbot Performance Feature Optimization 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:** Optimize the app's selfbot client runtime and vendor internals for lower memory pressure, safer REST retries, reduced voice cleanup leaks, faster gateway queue processing, and lightweight observability.
|
||||
|
||||
**Architecture:** Start with app-level client options because they are low-risk and immediately reduce cache pressure. Then patch vendor internals in isolated areas: REST manager/request handling, voice packet cleanup, and WebSocket shard queueing. Keep public imports and runtime APIs compatible with `discord.js-selfbot-v13` consumers.
|
||||
|
||||
**Tech Stack:** Node.js, TypeScript, CommonJS vendor package, discord.js-selfbot-v13 workspace dependency, Undici, Vitest, Biome, TypeScript.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `src/index.ts`: instantiate `Client` with low-memory cache/sweeper/REST options.
|
||||
- Modify `vendor/discord.js-selfbot-v13/src/rest/RESTManager.js`: own per-client dispatcher state and super-properties cache helpers.
|
||||
- Modify `vendor/discord.js-selfbot-v13/src/rest/APIRequest.js`: use per-client dispatcher and cached `x-super-properties` header.
|
||||
- Modify `vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js`: add backoff/jitter helper and debug telemetry for retry attempts.
|
||||
- Modify `vendor/discord.js-selfbot-v13/src/client/voice/receiver/PacketHandler.js`: clear speaking timers and reduce RTP parse allocations.
|
||||
- Modify `vendor/discord.js-selfbot-v13/src/client/websocket/WebSocketShard.js`: use cursor-backed gateway queue with compatible priority insertion and destroy cleanup.
|
||||
- Create `tests/vendor/selfbotClientOptions.test.ts`: verify app client options factory if extracted.
|
||||
- Create `tests/vendor/requestHandlerBackoff.test.ts`: verify retry delay calculation is bounded and grows.
|
||||
- Create `tests/vendor/websocketQueue.test.ts`: verify FIFO, priority, and destroy queue reset semantics for the new queue helpers if exported/testable.
|
||||
|
||||
## Task 1: Extract and Test Low-Memory Client Options
|
||||
|
||||
**Files:**
|
||||
- Create: `src/discordClientOptions.ts`
|
||||
- Modify: `src/index.ts:4-25`
|
||||
- Test: `tests/vendor/selfbotClientOptions.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/vendor/selfbotClientOptions.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDiscordClientOptions } from "../../src/discordClientOptions";
|
||||
|
||||
describe("createDiscordClientOptions", () => {
|
||||
it("uses low-memory message cache and active sweepers", () => {
|
||||
const options = createDiscordClientOptions();
|
||||
|
||||
expect(options.restRequestTimeout).toBe(15_000);
|
||||
expect(options.retryLimit).toBe(2);
|
||||
expect(options.restGlobalRateLimit).toBe(45);
|
||||
expect(options.sweepers).toEqual({
|
||||
messages: { interval: 300, lifetime: 600 },
|
||||
threads: { interval: 3600, lifetime: 14400 },
|
||||
});
|
||||
|
||||
expect(options.partials).toEqual(["USER", "CHANNEL", "GUILD_MEMBER", "MESSAGE"]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `pnpm exec vitest run tests/vendor/selfbotClientOptions.test.ts`
|
||||
|
||||
Expected: FAIL with module not found for `src/discordClientOptions`.
|
||||
|
||||
- [ ] **Step 3: Add the client options factory**
|
||||
|
||||
Create `src/discordClientOptions.ts`:
|
||||
|
||||
```ts
|
||||
import { Options } from "discord.js-selfbot-v13";
|
||||
|
||||
export function createDiscordClientOptions() {
|
||||
return {
|
||||
makeCache: Options.cacheWithLimits({
|
||||
...Options.defaultMakeCacheSettings,
|
||||
MessageManager: 25,
|
||||
ReactionManager: 0,
|
||||
ReactionUserManager: 0,
|
||||
PresenceManager: 0,
|
||||
}),
|
||||
partials: ["USER", "CHANNEL", "GUILD_MEMBER", "MESSAGE"],
|
||||
sweepers: {
|
||||
messages: { interval: 300, lifetime: 600 },
|
||||
threads: { interval: 3600, lifetime: 14400 },
|
||||
},
|
||||
restRequestTimeout: 15_000,
|
||||
retryLimit: 2,
|
||||
restGlobalRateLimit: 45,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Use the factory in the app entry point**
|
||||
|
||||
Modify `src/index.ts`:
|
||||
|
||||
```ts
|
||||
import { Client } from "discord.js-selfbot-v13";
|
||||
import { config } from "./config";
|
||||
import { closeDatabase, initializeDatabase } from "./database/drizzle";
|
||||
import { createDiscordClientOptions } from "./discordClientOptions";
|
||||
```
|
||||
|
||||
Replace:
|
||||
|
||||
```ts
|
||||
const client = new Client();
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
const client = new Client(createDiscordClientOptions());
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the focused test**
|
||||
|
||||
Run: `pnpm exec vitest run tests/vendor/selfbotClientOptions.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Run typecheck**
|
||||
|
||||
Run: `pnpm run typecheck`
|
||||
|
||||
Expected: PASS. If TypeScript cannot type `Options` from the vendor package, add a local return type only if necessary; do not weaken the factory to `any`.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/discordClientOptions.ts src/index.ts tests/vendor/selfbotClientOptions.test.ts
|
||||
git commit -m "perf: tune selfbot client runtime options"
|
||||
```
|
||||
|
||||
## Task 2: Add Per-Client REST Dispatcher and Cached Super Properties
|
||||
|
||||
**Files:**
|
||||
- Modify: `vendor/discord.js-selfbot-v13/src/rest/RESTManager.js:1-69`
|
||||
- Modify: `vendor/discord.js-selfbot-v13/src/rest/APIRequest.js:1-166`
|
||||
|
||||
- [ ] **Step 1: Add REST manager state**
|
||||
|
||||
Modify `vendor/discord.js-selfbot-v13/src/rest/RESTManager.js` imports:
|
||||
|
||||
```js
|
||||
const { Collection } = require('@discordjs/collection');
|
||||
const makeFetchCookie = require('fetch-cookie');
|
||||
const { CookieJar } = require('tough-cookie');
|
||||
const { buildConnector, Client: UndiciClient, ProxyAgent, fetch: fetchOriginal } = require('undici');
|
||||
const APIRequest = require('./APIRequest');
|
||||
const routeBuilder = require('./APIRouter');
|
||||
const RequestHandler = require('./RequestHandler');
|
||||
const { Error } = require('../errors');
|
||||
const { ciphers } = require('../util/Constants');
|
||||
const { Endpoints } = require('../util/Constants');
|
||||
const Util = require('../util/Util');
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add per-client dispatcher fields and helper methods**
|
||||
|
||||
Inside `RESTManager` constructor after `this.fetch = ...`, add:
|
||||
|
||||
```js
|
||||
this.dispatcher = null;
|
||||
this.superPropertiesSource = null;
|
||||
this.superPropertiesHeader = null;
|
||||
```
|
||||
|
||||
Add methods before `request(method, url, options = {})`:
|
||||
|
||||
```js
|
||||
getDispatcher() {
|
||||
if (this.dispatcher) return this.dispatcher;
|
||||
|
||||
const proxy = Util.checkUndiciProxyAgent(this.client.options.http.agent);
|
||||
if (proxy) {
|
||||
this.dispatcher = new ProxyAgent({
|
||||
...proxy,
|
||||
ciphers: ciphers.join(':'),
|
||||
});
|
||||
} else {
|
||||
this.dispatcher = new UndiciClient('https://discord.com', {
|
||||
connect: buildConnector({ ciphers: ciphers.join(':') }),
|
||||
});
|
||||
}
|
||||
|
||||
return this.dispatcher;
|
||||
}
|
||||
|
||||
getSuperPropertiesHeader() {
|
||||
const source = JSON.stringify(this.client.options.ws.properties);
|
||||
if (source !== this.superPropertiesSource) {
|
||||
this.superPropertiesSource = source;
|
||||
this.superPropertiesHeader = Buffer.from(source, 'ascii').toString('base64');
|
||||
}
|
||||
return this.superPropertiesHeader;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove module-global dispatcher from APIRequest**
|
||||
|
||||
Modify `vendor/discord.js-selfbot-v13/src/rest/APIRequest.js` imports to:
|
||||
|
||||
```js
|
||||
const Buffer = require('node:buffer').Buffer;
|
||||
const { setTimeout } = require('node:timers');
|
||||
const { FormData } = require('undici');
|
||||
```
|
||||
|
||||
Remove:
|
||||
|
||||
```js
|
||||
const { FormData, buildConnector, Client, ProxyAgent } = require('undici');
|
||||
const { ciphers } = require('../util/Constants');
|
||||
const Util = require('../util/Util');
|
||||
|
||||
let agent = null;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Use REST manager dispatcher and cached header**
|
||||
|
||||
In `APIRequest.make`, delete the `if (!agent) { ... }` block.
|
||||
|
||||
Replace the `x-super-properties` header construction with:
|
||||
|
||||
```js
|
||||
'x-super-properties': this.rest.getSuperPropertiesHeader(),
|
||||
```
|
||||
|
||||
Replace fetch dispatcher:
|
||||
|
||||
```js
|
||||
dispatcher: agent,
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```js
|
||||
dispatcher: this.rest.getDispatcher(),
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run vendor lint through root lint**
|
||||
|
||||
Run: `pnpm run lint`
|
||||
|
||||
Expected: PASS or existing unrelated lint failures. If failures are in edited vendor files, fix them.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add vendor/discord.js-selfbot-v13/src/rest/RESTManager.js vendor/discord.js-selfbot-v13/src/rest/APIRequest.js
|
||||
git commit -m "perf: cache selfbot rest dispatcher metadata"
|
||||
```
|
||||
|
||||
## Task 3: Add REST Retry Backoff With Jitter
|
||||
|
||||
**Files:**
|
||||
- Modify: `vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js:1-505`
|
||||
- Test: `tests/vendor/requestHandlerBackoff.test.ts`
|
||||
|
||||
- [ ] **Step 1: Export a pure backoff helper for tests**
|
||||
|
||||
Add near the top of `vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js` after `calculateReset`:
|
||||
|
||||
```js
|
||||
function calculateRetryDelay(retryCount, random = Math.random) {
|
||||
const base = 250;
|
||||
const max = 5_000;
|
||||
const exponential = Math.min(max, base * 2 ** Math.max(0, retryCount - 1));
|
||||
return exponential + Math.floor(random() * base);
|
||||
}
|
||||
```
|
||||
|
||||
At the bottom, replace:
|
||||
|
||||
```js
|
||||
module.exports = RequestHandler;
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```js
|
||||
module.exports = RequestHandler;
|
||||
module.exports.calculateRetryDelay = calculateRetryDelay;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the focused helper test**
|
||||
|
||||
Create `tests/vendor/requestHandlerBackoff.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { calculateRetryDelay } = await import(
|
||||
"../../vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js"
|
||||
);
|
||||
|
||||
describe("calculateRetryDelay", () => {
|
||||
it("increases exponentially and applies bounded jitter", () => {
|
||||
expect(calculateRetryDelay(1, () => 0)).toBe(250);
|
||||
expect(calculateRetryDelay(2, () => 0)).toBe(500);
|
||||
expect(calculateRetryDelay(3, () => 0)).toBe(1000);
|
||||
expect(calculateRetryDelay(10, () => 0)).toBe(5000);
|
||||
expect(calculateRetryDelay(1, () => 0.999)).toBe(499);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the helper test**
|
||||
|
||||
Run: `pnpm exec vitest run tests/vendor/requestHandlerBackoff.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Apply backoff to network errors**
|
||||
|
||||
In `RequestHandler.execute`, replace the catch block after `request.make(...)` with:
|
||||
|
||||
```js
|
||||
} catch (error) {
|
||||
if (request.retries === this.manager.client.options.retryLimit) {
|
||||
throw new HTTPError(
|
||||
error.message,
|
||||
error.constructor.name,
|
||||
error.status,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
request.retries++;
|
||||
const delay = calculateRetryDelay(request.retries);
|
||||
this.manager.client.emit(
|
||||
DEBUG,
|
||||
`[Request Handler] Retrying failed request after ${delay}ms.\n Method : ${request.method}\n Path : ${request.path}\n Route : ${request.route}\n Retry : ${request.retries}`,
|
||||
);
|
||||
await sleep(delay);
|
||||
return this.execute(request);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Apply backoff to 5xx responses**
|
||||
|
||||
In the 5xx block, replace:
|
||||
|
||||
```js
|
||||
request.retries++;
|
||||
return this.execute(request);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```js
|
||||
request.retries++;
|
||||
const delay = calculateRetryDelay(request.retries);
|
||||
this.manager.client.emit(
|
||||
DEBUG,
|
||||
`[Request Handler] Retrying server error after ${delay}ms.\n Method : ${request.method}\n Path : ${request.path}\n Route : ${request.route}\n Status : ${res.status}\n Retry : ${request.retries}`,
|
||||
);
|
||||
await sleep(delay);
|
||||
return this.execute(request);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run focused and full tests**
|
||||
|
||||
Run: `pnpm exec vitest run tests/vendor/requestHandlerBackoff.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
Run: `pnpm run test`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js tests/vendor/requestHandlerBackoff.test.ts
|
||||
git commit -m "perf: back off selfbot rest retries"
|
||||
```
|
||||
|
||||
## Task 4: Clean Voice Receiver Timers and Reduce RTP Buffer Work
|
||||
|
||||
**Files:**
|
||||
- Modify: `vendor/discord.js-selfbot-v13/src/client/voice/receiver/PacketHandler.js:1-280`
|
||||
|
||||
- [ ] **Step 1: Patch AES decrypt concat allocation**
|
||||
|
||||
In `parseBuffer`, replace:
|
||||
|
||||
```js
|
||||
packet = Buffer.concat([
|
||||
decipheriv.update(encrypted),
|
||||
decipheriv.final(),
|
||||
]);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```js
|
||||
const updated = decipheriv.update(encrypted);
|
||||
const final = decipheriv.final();
|
||||
packet = final.length === 0 ? updated : Buffer.concat([updated, final]);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Patch XChaCha auth tag concat allocation**
|
||||
|
||||
Replace:
|
||||
|
||||
```js
|
||||
Buffer.concat([encrypted, authTag]),
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```js
|
||||
buffer.subarray(headerSize, buffer.length - UNPADDED_NONCE_LENGTH),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add speaking timeout cleanup**
|
||||
|
||||
In `destroyAllStream()`, after clearing video streams, add:
|
||||
|
||||
```js
|
||||
for (const timeout of this.speakingTimeouts.values()) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const clearedSpeakingTimeouts = this.speakingTimeouts.size;
|
||||
this.speakingTimeouts.clear();
|
||||
this.emit('debug', {
|
||||
message: 'Destroyed voice receiver streams',
|
||||
audioStreams: this.streams.size,
|
||||
videoStreams: this.videoStreams.size,
|
||||
speakingTimeouts: clearedSpeakingTimeouts,
|
||||
});
|
||||
```
|
||||
|
||||
Then adjust ordering so the counts are captured before `streams.clear()` and `videoStreams.clear()`:
|
||||
|
||||
```js
|
||||
destroyAllStream() {
|
||||
const audioStreams = this.streams.size;
|
||||
const videoStreams = this.videoStreams.size;
|
||||
for (const stream of this.streams.values()) {
|
||||
stream.stream.destroy();
|
||||
}
|
||||
this.streams.clear();
|
||||
for (const stream of this.videoStreams.values()) {
|
||||
stream.destroy();
|
||||
}
|
||||
this.videoStreams.clear();
|
||||
for (const timeout of this.speakingTimeouts.values()) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const speakingTimeouts = this.speakingTimeouts.size;
|
||||
this.speakingTimeouts.clear();
|
||||
this.emit('debug', {
|
||||
message: 'Destroyed voice receiver streams',
|
||||
audioStreams,
|
||||
videoStreams,
|
||||
speakingTimeouts,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run lint**
|
||||
|
||||
Run: `pnpm run lint`
|
||||
|
||||
Expected: PASS or only unrelated existing failures. Fix edited-file failures.
|
||||
|
||||
- [ ] **Step 5: Run tests**
|
||||
|
||||
Run: `pnpm run test`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add vendor/discord.js-selfbot-v13/src/client/voice/receiver/PacketHandler.js
|
||||
git commit -m "perf: clean up selfbot voice receiver state"
|
||||
```
|
||||
|
||||
## Task 5: Replace Gateway Queue Shift With Cursor Queue
|
||||
|
||||
**Files:**
|
||||
- Modify: `vendor/discord.js-selfbot-v13/src/client/websocket/WebSocketShard.js:108-120,818-954`
|
||||
|
||||
- [ ] **Step 1: Add queue cursor metadata**
|
||||
|
||||
In the `ratelimit` object, change:
|
||||
|
||||
```js
|
||||
queue: [],
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```js
|
||||
queue: [],
|
||||
queueOffset: 0,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update priority insertion**
|
||||
|
||||
Replace `send(data, important = false)` with:
|
||||
|
||||
```js
|
||||
send(data, important = false) {
|
||||
if (important) {
|
||||
if (this.ratelimit.queueOffset === 0) {
|
||||
this.ratelimit.queue.unshift(data);
|
||||
} else {
|
||||
this.ratelimit.queue[--this.ratelimit.queueOffset] = data;
|
||||
}
|
||||
} else {
|
||||
this.ratelimit.queue.push(data);
|
||||
}
|
||||
this.processQueue();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update queue processing**
|
||||
|
||||
Replace `processQueue()` with:
|
||||
|
||||
```js
|
||||
processQueue() {
|
||||
if (this.ratelimit.remaining === 0) return;
|
||||
if (this.ratelimit.queueOffset >= this.ratelimit.queue.length) return;
|
||||
if (this.ratelimit.remaining === this.ratelimit.total) {
|
||||
this.ratelimit.timer = setTimeout(() => {
|
||||
this.ratelimit.remaining = this.ratelimit.total;
|
||||
this.processQueue();
|
||||
}, this.ratelimit.time).unref();
|
||||
}
|
||||
while (this.ratelimit.remaining > 0) {
|
||||
const item = this.ratelimit.queue[this.ratelimit.queueOffset++];
|
||||
if (!item) {
|
||||
this._compactQueue();
|
||||
return;
|
||||
}
|
||||
this._send(item);
|
||||
this.ratelimit.remaining--;
|
||||
}
|
||||
this._compactQueue();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add queue compaction helper**
|
||||
|
||||
Add before `destroy(...)`:
|
||||
|
||||
```js
|
||||
_compactQueue() {
|
||||
if (this.ratelimit.queueOffset === 0) return;
|
||||
if (this.ratelimit.queueOffset >= this.ratelimit.queue.length) {
|
||||
this.ratelimit.queue.length = 0;
|
||||
this.ratelimit.queueOffset = 0;
|
||||
return;
|
||||
}
|
||||
if (this.ratelimit.queueOffset > 512) {
|
||||
this.ratelimit.queue = this.ratelimit.queue.slice(this.ratelimit.queueOffset);
|
||||
this.ratelimit.queueOffset = 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Reset cursor on destroy**
|
||||
|
||||
In `destroy`, after:
|
||||
|
||||
```js
|
||||
this.ratelimit.queue.length = 0;
|
||||
```
|
||||
|
||||
Add:
|
||||
|
||||
```js
|
||||
this.ratelimit.queueOffset = 0;
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run lint and tests**
|
||||
|
||||
Run: `pnpm run lint`
|
||||
|
||||
Expected: PASS or only unrelated existing failures. Fix edited-file failures.
|
||||
|
||||
Run: `pnpm run test`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add vendor/discord.js-selfbot-v13/src/client/websocket/WebSocketShard.js
|
||||
git commit -m "perf: optimize selfbot gateway send queue"
|
||||
```
|
||||
|
||||
## Task 6: Final Verification and Manual Runtime Notes
|
||||
|
||||
**Files:**
|
||||
- Modify only if verification exposes issues.
|
||||
|
||||
- [ ] **Step 1: Run full lint**
|
||||
|
||||
Run: `pnpm run lint`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run full typecheck**
|
||||
|
||||
Run: `pnpm run typecheck`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run full tests**
|
||||
|
||||
Run: `pnpm run test`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run build**
|
||||
|
||||
Run: `pnpm run build`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Inspect git diff**
|
||||
|
||||
Run: `git diff --stat HEAD~5..HEAD` if each task was committed, or `git diff --stat` if not.
|
||||
|
||||
Expected: changes limited to app client options, vendor REST, vendor voice, vendor WebSocket, tests, and this plan/spec.
|
||||
|
||||
- [ ] **Step 6: Record manual Discord runtime limitation**
|
||||
|
||||
If no Discord token/runtime environment is available, final response must state:
|
||||
|
||||
```text
|
||||
Automated verification passed. I could not perform live Discord runtime verification in this environment. Manual checks still needed: login, message capture, backlog sync, voice connect, voice record, disconnect, reconnect.
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit verification fixes only if needed**
|
||||
|
||||
If Step 1-4 required fixes, commit only those fixes:
|
||||
|
||||
```bash
|
||||
git add <fixed-files>
|
||||
git commit -m "fix: stabilize selfbot optimization verification"
|
||||
```
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: app runtime config is Task 1; REST dispatcher/header/backoff is Tasks 2-3; voice cleanup/allocation is Task 4; gateway queue is Task 5; verification/manual runtime note is Task 6.
|
||||
- Placeholder scan: no TBD/TODO/fill-in steps remain; each code step includes concrete snippets and paths.
|
||||
- Type consistency: `createDiscordClientOptions`, `calculateRetryDelay`, `getDispatcher`, `getSuperPropertiesHeader`, `_compactQueue`, and `queueOffset` are introduced before use and named consistently.
|
||||
239
docs/superpowers/plans/2026-05-15-selfbot-workspace-submodule.md
Normal file
239
docs/superpowers/plans/2026-05-15-selfbot-workspace-submodule.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Selfbot Workspace Submodule 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:** Replace the npm `discord.js-selfbot-v13` dependency with a custom git submodule consumed through pnpm workspace resolution.
|
||||
|
||||
**Architecture:** The vendored selfbot library lives at `vendor/discord.js-selfbot-v13` as a git submodule. The root package depends on it with `workspace:*`, and `pnpm-workspace.yaml` includes both the root package and the vendored package while preserving the existing `onlyBuiltDependencies` settings.
|
||||
|
||||
**Tech Stack:** Git submodules, pnpm workspaces, TypeScript, existing Node.js package scripts.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create: `.gitmodules` if missing; otherwise modify it to include `vendor/discord.js-selfbot-v13`.
|
||||
- Create: `vendor/discord.js-selfbot-v13` via `git submodule add`; do not create files in this directory manually.
|
||||
- Modify: `pnpm-workspace.yaml` to add `packages` while preserving `onlyBuiltDependencies`.
|
||||
- Modify: `package.json` dependency `discord.js-selfbot-v13` from `^3.7.1` to `workspace:*`.
|
||||
- Modify: `pnpm-lock.yaml` by running pnpm, not by hand.
|
||||
|
||||
### Task 1: Add the selfbot repository as a submodule
|
||||
|
||||
**Files:**
|
||||
- Create/Modify: `.gitmodules`
|
||||
- Create: `vendor/discord.js-selfbot-v13`
|
||||
|
||||
- [ ] **Step 1: Confirm there is no existing submodule path**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git submodule status --recursive || true
|
||||
test ! -e vendor/discord.js-selfbot-v13
|
||||
```
|
||||
|
||||
Expected: either no existing submodule output, or output that does not include `vendor/discord.js-selfbot-v13`; the `test` command exits successfully.
|
||||
|
||||
- [ ] **Step 2: Add the upstream repository as a submodule**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git submodule add https://github.com/aiko-chan-ai/discord.js-selfbot-v13.git vendor/discord.js-selfbot-v13
|
||||
```
|
||||
|
||||
Expected: git clones the repository into `vendor/discord.js-selfbot-v13` and creates or updates `.gitmodules`.
|
||||
|
||||
- [ ] **Step 3: Change the submodule remote to the internal SSH repository**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git -C vendor/discord.js-selfbot-v13 remote set-url origin ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
|
||||
git config -f .gitmodules submodule.vendor/discord.js-selfbot-v13.url ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
|
||||
git submodule sync vendor/discord.js-selfbot-v13
|
||||
```
|
||||
|
||||
Expected: both the submodule checkout and `.gitmodules` use `ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git`.
|
||||
|
||||
- [ ] **Step 4: Verify submodule metadata**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git -C vendor/discord.js-selfbot-v13 remote get-url origin
|
||||
git config -f .gitmodules --get submodule.vendor/discord.js-selfbot-v13.path
|
||||
git config -f .gitmodules --get submodule.vendor/discord.js-selfbot-v13.url
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```text
|
||||
ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
|
||||
vendor/discord.js-selfbot-v13
|
||||
ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
|
||||
```
|
||||
|
||||
### Task 2: Configure pnpm workspace resolution
|
||||
|
||||
**Files:**
|
||||
- Modify: `pnpm-workspace.yaml`
|
||||
- Modify: `package.json`
|
||||
|
||||
- [ ] **Step 1: Update pnpm workspace file**
|
||||
|
||||
Edit `pnpm-workspace.yaml` to exactly:
|
||||
|
||||
```yaml
|
||||
packages:
|
||||
- .
|
||||
- vendor/discord.js-selfbot-v13
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@discordjs/opus'
|
||||
- better-sqlite3
|
||||
- esbuild
|
||||
```
|
||||
|
||||
Expected: the existing `onlyBuiltDependencies` entries remain unchanged, and workspace packages now include root plus the vendored selfbot package.
|
||||
|
||||
- [ ] **Step 2: Update root dependency**
|
||||
|
||||
Edit `package.json` so the dependencies block contains:
|
||||
|
||||
```json
|
||||
"discord.js-selfbot-v13": "workspace:*"
|
||||
```
|
||||
|
||||
Expected: only the `discord.js-selfbot-v13` version source changes; the rest of `package.json` remains unchanged.
|
||||
|
||||
- [ ] **Step 3: Verify the submodule package name**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node -e "const p=require('./vendor/discord.js-selfbot-v13/package.json'); if (p.name !== 'discord.js-selfbot-v13') { throw new Error('unexpected package name: '+p.name) } console.log(p.name)"
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```text
|
||||
discord.js-selfbot-v13
|
||||
```
|
||||
|
||||
### Task 3: Refresh dependency lockfile and install links
|
||||
|
||||
**Files:**
|
||||
- Modify: `pnpm-lock.yaml`
|
||||
- Modify: `node_modules` locally, not committed
|
||||
|
||||
- [ ] **Step 1: Refresh pnpm install state**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Expected: pnpm completes successfully and updates `pnpm-lock.yaml` so `discord.js-selfbot-v13` resolves from `link:vendor/discord.js-selfbot-v13` or equivalent workspace link notation.
|
||||
|
||||
- [ ] **Step 2: Verify pnpm resolves the workspace package**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm list discord.js-selfbot-v13 --depth 0
|
||||
```
|
||||
|
||||
Expected: output shows `discord.js-selfbot-v13` as a linked workspace dependency rather than the npm registry version.
|
||||
|
||||
- [ ] **Step 3: Inspect the lockfile entry**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -n "discord.js-selfbot-v13" pnpm-lock.yaml | head -20
|
||||
```
|
||||
|
||||
Expected: the root importer entry for `discord.js-selfbot-v13` references `specifier: workspace:*` and a workspace/link version.
|
||||
|
||||
### Task 4: Validate root project compatibility
|
||||
|
||||
**Files:**
|
||||
- Read-only validation for TypeScript project files.
|
||||
|
||||
- [ ] **Step 1: Run TypeScript validation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
Expected: command exits successfully.
|
||||
|
||||
- [ ] **Step 2: If typecheck fails because the submodule package is unbuilt, build the submodule**
|
||||
|
||||
Run only if Step 1 fails with missing compiled files or missing package entrypoint errors from `vendor/discord.js-selfbot-v13`:
|
||||
|
||||
```bash
|
||||
pnpm --filter discord.js-selfbot-v13 install
|
||||
npnpm --filter discord.js-selfbot-v13 run build
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
Expected: submodule package builds successfully and root typecheck passes.
|
||||
|
||||
If the package has no `build` script, inspect `vendor/discord.js-selfbot-v13/package.json` scripts and use the package's documented compile script, then rerun `pnpm run typecheck`.
|
||||
|
||||
- [ ] **Step 3: Run lint if typecheck passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm run lint
|
||||
```
|
||||
|
||||
Expected: command exits successfully or reports only pre-existing issues unrelated to `.gitmodules`, `package.json`, `pnpm-workspace.yaml`, or `pnpm-lock.yaml`.
|
||||
|
||||
### Task 5: Review git diff and prepare handoff
|
||||
|
||||
**Files:**
|
||||
- Review: `.gitmodules`
|
||||
- Review: `package.json`
|
||||
- Review: `pnpm-workspace.yaml`
|
||||
- Review: `pnpm-lock.yaml`
|
||||
- Review: `vendor/discord.js-selfbot-v13` gitlink
|
||||
|
||||
- [ ] **Step 1: Review changed files**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff -- .gitmodules package.json pnpm-workspace.yaml pnpm-lock.yaml
|
||||
git diff --submodule
|
||||
```
|
||||
|
||||
Expected: changes are limited to the design spec, plan, submodule metadata/gitlink, pnpm workspace config, root dependency, and lockfile. Existing unrelated `README.md` modifications remain untouched.
|
||||
|
||||
- [ ] **Step 2: Summarize validation evidence**
|
||||
|
||||
Record these command outcomes in the final response:
|
||||
|
||||
```text
|
||||
pnpm install: PASS or FAIL with error summary
|
||||
pnpm run typecheck: PASS or FAIL with error summary
|
||||
pnpm run lint: PASS, FAIL with error summary, or NOT RUN with reason
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Do not commit unless explicitly asked**
|
||||
|
||||
No commit command should run unless the user explicitly asks for a commit. If the user asks, use the repository commit workflow and stage only relevant files.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: the plan covers submodule creation, remote replacement, workspace config, dependency rewrite, lockfile refresh, and validation.
|
||||
- Placeholder scan: no TBD/TODO placeholders remain.
|
||||
- Type consistency: package path, dependency name, and remote URL are consistent across tasks.
|
||||
466
docs/superpowers/plans/2026-05-15-split-text-voice-selection.md
Normal file
466
docs/superpowers/plans/2026-05-15-split-text-voice-selection.md
Normal file
@@ -0,0 +1,466 @@
|
||||
# Split Text Voice Selection Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Separate text moderation guild/channel selection from voice recording guild/channel selection in config, backend state, and dashboard UI.
|
||||
|
||||
**Architecture:** Add explicit text and voice config keys while keeping legacy `MONITOR_GUILD_ID` and `GUILD_ID` as fallbacks. Split shared UI state into `selectedTextGuild`/`selectedTextChannel` and `selectedVoiceGuild`/`selectedVoiceChannel`, with backward-compatible migration from old persisted `selectedGuild`. Update capture/backlog to use text-specific settings and voice routes to update only voice-specific state.
|
||||
|
||||
**Tech Stack:** TypeScript, Zod config, Express routes, Discord selfbot client, Vitest, static dashboard JavaScript.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `src/config.ts`: add `TEXT_GUILD_ID`, `TEXT_CHANNEL_ID`, `VOICE_GUILD_ID`; derive effective text/voice IDs with legacy fallbacks.
|
||||
- Modify `.env.example`: document split text/voice configuration.
|
||||
- Modify `src/moderation/messageCapture.ts`: filter live capture by effective text guild and optional text channel.
|
||||
- Modify `src/moderation/backlogSync.ts`: use effective text guild and optional text channel for readiness/on-demand sync.
|
||||
- Modify `src/webserver.ts`: change `SharedUIState` to split text/voice guild fields and migrate old persisted state.
|
||||
- Modify `src/routes/uiStateRoutes.ts`: update shared UI state type.
|
||||
- Modify `src/routes/voiceRoutes.ts`: patch `selectedVoiceGuild` only on connect/disconnect.
|
||||
- Modify `public/index.html`: add separate voice guild select and text guild select behavior.
|
||||
- Tests: `tests/config.test.ts`, `tests/moderation/messageCapture.test.ts`, and a new UI state route/unit test if needed.
|
||||
|
||||
## Task 1: Split Config Defaults
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/config.ts`
|
||||
- Modify: `.env.example`
|
||||
- Test: `tests/config.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing config tests**
|
||||
|
||||
Add tests to `tests/config.test.ts`:
|
||||
|
||||
```ts
|
||||
it("derives split text and voice guild defaults from legacy config", async () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
DISCORD_TOKEN: "token",
|
||||
MONITOR_GUILD_ID: "legacy-text-guild",
|
||||
GUILD_ID: "legacy-voice-guild",
|
||||
VOICE_CHANNEL_ID: "voice-channel",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
|
||||
const { loadConfig } = await import("../src/config");
|
||||
const config = loadConfig(process.env);
|
||||
|
||||
expect(config.TEXT_GUILD_ID).toBeUndefined();
|
||||
expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("legacy-text-guild");
|
||||
expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("legacy-voice-guild");
|
||||
expect(config.VOICE_CHANNEL_ID).toBe("voice-channel");
|
||||
});
|
||||
|
||||
it("uses explicit split text and voice config before legacy values", async () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
DISCORD_TOKEN: "token",
|
||||
MONITOR_GUILD_ID: "legacy-text-guild",
|
||||
GUILD_ID: "legacy-voice-guild",
|
||||
TEXT_GUILD_ID: "text-guild",
|
||||
TEXT_CHANNEL_ID: "text-channel",
|
||||
VOICE_GUILD_ID: "voice-guild",
|
||||
VOICE_CHANNEL_ID: "voice-channel",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
|
||||
const { loadConfig } = await import("../src/config");
|
||||
const config = loadConfig(process.env);
|
||||
|
||||
expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("text-guild");
|
||||
expect(config.TEXT_CHANNEL_ID).toBe("text-channel");
|
||||
expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("voice-guild");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run config tests red**
|
||||
|
||||
Run: `pnpm exec vitest run tests/config.test.ts`
|
||||
|
||||
Expected: FAIL because `EFFECTIVE_TEXT_GUILD_ID` and `EFFECTIVE_VOICE_GUILD_ID` do not exist.
|
||||
|
||||
- [ ] **Step 3: Add split config fields and derived values**
|
||||
|
||||
In `src/config.ts`, add schema fields near legacy guild config:
|
||||
|
||||
```ts
|
||||
TEXT_GUILD_ID: z.string().min(1).optional(),
|
||||
TEXT_CHANNEL_ID: z.string().min(1).optional(),
|
||||
VOICE_GUILD_ID: z.string().min(1).optional(),
|
||||
```
|
||||
|
||||
Change `loadConfig` to parse then return derived values:
|
||||
|
||||
```ts
|
||||
const parsed = configSchema.parse(env);
|
||||
return {
|
||||
...parsed,
|
||||
EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID,
|
||||
EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID,
|
||||
};
|
||||
```
|
||||
|
||||
Update `AppConfig` to include derived fields:
|
||||
|
||||
```ts
|
||||
export type AppConfig = z.infer<typeof configSchema> & {
|
||||
EFFECTIVE_TEXT_GUILD_ID?: string;
|
||||
EFFECTIVE_VOICE_GUILD_ID?: string;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `.env.example`**
|
||||
|
||||
Document:
|
||||
|
||||
```env
|
||||
# Text moderation capture target. Falls back to MONITOR_GUILD_ID for compatibility.
|
||||
TEXT_GUILD_ID=
|
||||
TEXT_CHANNEL_ID=
|
||||
|
||||
# Voice recording default target. Falls back to GUILD_ID for compatibility.
|
||||
VOICE_GUILD_ID=
|
||||
VOICE_CHANNEL_ID=
|
||||
```
|
||||
|
||||
Keep existing legacy keys with notes rather than deleting them.
|
||||
|
||||
- [ ] **Step 5: Run config tests green**
|
||||
|
||||
Run: `pnpm exec vitest run tests/config.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 2: Apply Text Capture Guild/Channel Filtering
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/moderation/messageCapture.ts`
|
||||
- Modify: `src/moderation/backlogSync.ts`
|
||||
- Test: `tests/moderation/messageCapture.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing channel filter test**
|
||||
|
||||
In `tests/moderation/messageCapture.test.ts`, mock config before importing `captureMessage` if needed or add a new test file `tests/moderation/messageCaptureFilter.test.ts` that imports a new exported helper.
|
||||
|
||||
Preferred helper test: create `tests/moderation/messageCaptureFilter.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldCaptureMessageLocation } from "../../src/moderation/messageCapture";
|
||||
|
||||
describe("shouldCaptureMessageLocation", () => {
|
||||
it("matches only configured text guild and optional channel", () => {
|
||||
expect(
|
||||
shouldCaptureMessageLocation(
|
||||
{ guildId: "guild-1", channelId: "channel-1" },
|
||||
{ guildId: "guild-1", channelId: "channel-1" },
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldCaptureMessageLocation(
|
||||
{ guildId: "guild-1", channelId: "channel-2" },
|
||||
{ guildId: "guild-1", channelId: "channel-1" },
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldCaptureMessageLocation(
|
||||
{ guildId: "guild-2", channelId: "channel-1" },
|
||||
{ guildId: "guild-1", channelId: "channel-1" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run filter test red**
|
||||
|
||||
Run: `pnpm exec vitest run tests/moderation/messageCaptureFilter.test.ts`
|
||||
|
||||
Expected: FAIL because `shouldCaptureMessageLocation` does not exist.
|
||||
|
||||
- [ ] **Step 3: Add capture filter helper**
|
||||
|
||||
In `src/moderation/messageCapture.ts`, export:
|
||||
|
||||
```ts
|
||||
export interface TextCaptureTarget {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
}
|
||||
|
||||
export interface MessageLocationInput {
|
||||
guildId?: string | null;
|
||||
channelId?: string | null;
|
||||
}
|
||||
|
||||
export function shouldCaptureMessageLocation(
|
||||
message: MessageLocationInput,
|
||||
target: TextCaptureTarget,
|
||||
): boolean {
|
||||
if (!message.guildId || message.guildId !== target.guildId) return false;
|
||||
if (target.channelId && message.channelId !== target.channelId) return false;
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
Replace event checks:
|
||||
|
||||
```ts
|
||||
if (
|
||||
!shouldCaptureMessageLocation(message, {
|
||||
guildId: config.EFFECTIVE_TEXT_GUILD_ID,
|
||||
channelId: config.TEXT_CHANNEL_ID,
|
||||
})
|
||||
)
|
||||
return;
|
||||
```
|
||||
|
||||
Use the same helper for `messageUpdate` and `messageDelete`.
|
||||
|
||||
- [ ] **Step 4: Update backlog sync config**
|
||||
|
||||
In `src/moderation/backlogSync.ts`, replace readiness checks with `config.EFFECTIVE_TEXT_GUILD_ID` and log names with `TEXT_GUILD_ID`. If `config.TEXT_CHANNEL_ID` is present in `syncBacklogMessages`, verify the channel exists and call `syncSelectedChannelBacklog(client, guild.id, config.TEXT_CHANNEL_ID)` instead of only logging readiness.
|
||||
|
||||
- [ ] **Step 5: Run focused moderation tests**
|
||||
|
||||
Run: `pnpm exec vitest run tests/moderation/messageCapture.test.ts tests/moderation/messageCaptureFilter.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 3: Split Shared UI State
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/webserver.ts`
|
||||
- Modify: `src/routes/uiStateRoutes.ts`
|
||||
- Modify: `src/routes/voiceRoutes.ts`
|
||||
- Test: create `tests/routes/uiStateRoutes.test.ts` if no existing route test fits.
|
||||
|
||||
- [ ] **Step 1: Write state migration test**
|
||||
|
||||
Create `tests/routes/uiStateRoutes.test.ts` with a pure helper import if extracted. Add helper in Task 3 implementation.
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeSharedUIState } from "../../src/webserver";
|
||||
|
||||
describe("normalizeSharedUIState", () => {
|
||||
it("migrates legacy selectedGuild into split text and voice guilds", () => {
|
||||
expect(
|
||||
normalizeSharedUIState({
|
||||
selectedGuild: "legacy-guild",
|
||||
selectedVoiceChannel: "voice-channel",
|
||||
selectedTextChannel: "text-channel",
|
||||
}),
|
||||
).toMatchObject({
|
||||
selectedVoiceGuild: "legacy-guild",
|
||||
selectedVoiceChannel: "voice-channel",
|
||||
selectedTextGuild: "legacy-guild",
|
||||
selectedTextChannel: "text-channel",
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run state test red**
|
||||
|
||||
Run: `pnpm exec vitest run tests/routes/uiStateRoutes.test.ts`
|
||||
|
||||
Expected: FAIL because `normalizeSharedUIState` does not exist/export.
|
||||
|
||||
- [ ] **Step 3: Update shared state types**
|
||||
|
||||
In `src/routes/uiStateRoutes.ts` and `src/webserver.ts`, replace `selectedGuild` with:
|
||||
|
||||
```ts
|
||||
selectedVoiceGuild: string;
|
||||
selectedVoiceChannel: string;
|
||||
selectedTextGuild: string;
|
||||
selectedTextChannel: string;
|
||||
```
|
||||
|
||||
Keep request patch compatibility by allowing `selectedGuild?: string` in the normalization helper input.
|
||||
|
||||
- [ ] **Step 4: Add normalizer and use it after persistence load**
|
||||
|
||||
In `src/webserver.ts`, export:
|
||||
|
||||
```ts
|
||||
export function normalizeSharedUIState(value: Partial<SharedUIState> & { selectedGuild?: string }): SharedUIState {
|
||||
const legacyGuild = value.selectedGuild ?? "";
|
||||
return {
|
||||
selectedVoiceGuild: value.selectedVoiceGuild ?? legacyGuild,
|
||||
selectedVoiceChannel: value.selectedVoiceChannel ?? "",
|
||||
selectedTextGuild: value.selectedTextGuild ?? legacyGuild,
|
||||
selectedTextChannel: value.selectedTextChannel ?? "",
|
||||
activeTab: value.activeTab === "text" ? "text" : "voice",
|
||||
isListening: value.isListening ?? false,
|
||||
isStreaming: value.isStreaming ?? false,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Use it in `initializeSharedUIState()`:
|
||||
|
||||
```ts
|
||||
sharedUIState = normalizeSharedUIState(
|
||||
await getPersistedValue("web-ui-state", defaultSharedUIState),
|
||||
);
|
||||
```
|
||||
|
||||
Update `patchSharedUIState` to accept `selectedVoiceGuild`, `selectedVoiceChannel`, `selectedTextGuild`, `selectedTextChannel`; if legacy `selectedGuild` arrives, set both guild fields.
|
||||
|
||||
- [ ] **Step 5: Update voice route patches**
|
||||
|
||||
In `src/routes/voiceRoutes.ts`, connect patch becomes:
|
||||
|
||||
```ts
|
||||
selectedVoiceGuild: guildId,
|
||||
selectedVoiceChannel: channelId,
|
||||
```
|
||||
|
||||
Disconnect clears only:
|
||||
|
||||
```ts
|
||||
selectedVoiceGuild: "",
|
||||
selectedVoiceChannel: "",
|
||||
```
|
||||
|
||||
Do not clear text guild/channel on voice disconnect.
|
||||
|
||||
- [ ] **Step 6: Run state tests**
|
||||
|
||||
Run: `pnpm exec vitest run tests/routes/uiStateRoutes.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 4: Update Static Dashboard Selection
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/index.html`
|
||||
|
||||
- [ ] **Step 1: Replace state fields**
|
||||
|
||||
Change JS state fields:
|
||||
|
||||
```js
|
||||
selectedVoiceGuild: '',
|
||||
selectedVoiceChannel: '',
|
||||
selectedTextGuild: '',
|
||||
selectedTextChannel: '',
|
||||
```
|
||||
|
||||
Remove direct reliance on `selectedGuild` except migration when applying server state.
|
||||
|
||||
- [ ] **Step 2: Add separate DOM selectors**
|
||||
|
||||
In the UI markup, provide separate select elements for voice guild and text guild. Use IDs:
|
||||
|
||||
```html
|
||||
<select id="voiceGuildSelect"></select>
|
||||
<select id="channelSelect"></select>
|
||||
<select id="textGuildSelect"></select>
|
||||
<select id="channelFilter"></select>
|
||||
```
|
||||
|
||||
Update the `el` map to use `voiceGuildSelect` and `textGuildSelect`.
|
||||
|
||||
- [ ] **Step 3: Split channel loading functions**
|
||||
|
||||
Replace `loadChannels(guildId)` with:
|
||||
|
||||
```js
|
||||
async function loadVoiceChannels(guildId) {
|
||||
if (!guildId) return renderOptions(el.channelSelect, [], 'Select voice channel');
|
||||
const voiceChannels = await apiRequest(`/api/guilds/${guildId}/voice-channels`);
|
||||
renderOptions(el.channelSelect, voiceChannels, 'Select voice channel');
|
||||
if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel;
|
||||
}
|
||||
|
||||
async function loadTextChannels(guildId) {
|
||||
if (!guildId) return renderOptions(el.channelFilter, [], 'Select channel');
|
||||
const watchChannels = await apiRequest(`/api/guilds/${guildId}/channels`);
|
||||
renderOptions(el.channelFilter, watchChannels, 'Select channel');
|
||||
if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel;
|
||||
apiRequest(`/api/guilds/${guildId}/threads`)
|
||||
.then((threads) => {
|
||||
appendOptions(el.channelFilter, threads);
|
||||
if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel;
|
||||
})
|
||||
.catch((error) => showError(`Thread discovery failed: ${error.message}`));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Split state application**
|
||||
|
||||
In `applyServerState`, compute:
|
||||
|
||||
```js
|
||||
const nextVoiceGuild = next.selectedVoiceGuild || next.selectedGuild || '';
|
||||
const nextTextGuild = next.selectedTextGuild || next.selectedGuild || '';
|
||||
const voiceGuildChanged = nextVoiceGuild !== state.selectedVoiceGuild;
|
||||
const textGuildChanged = nextTextGuild !== state.selectedTextGuild;
|
||||
```
|
||||
|
||||
Load voice channels only when voice guild changes; load text channels only when text guild changes. Backlog sync uses `state.selectedTextGuild`.
|
||||
|
||||
- [ ] **Step 5: Split event listeners**
|
||||
|
||||
Use:
|
||||
|
||||
```js
|
||||
el.voiceGuildSelect.addEventListener('change', () => postUIState({ selectedVoiceGuild: el.voiceGuildSelect.value, selectedVoiceChannel: '' }).catch((error) => showError(error.message)));
|
||||
el.textGuildSelect.addEventListener('change', () => postUIState({ selectedTextGuild: el.textGuildSelect.value, selectedTextChannel: '' }).catch((error) => showError(error.message)));
|
||||
el.channelSelect.addEventListener('change', () => postUIState({ selectedVoiceChannel: el.channelSelect.value }).catch((error) => showError(error.message)));
|
||||
el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.textGuildSelect.value) url.searchParams.set('guild', el.textGuildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); });
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Manual UI verification**
|
||||
|
||||
Run: `pnpm run build`
|
||||
|
||||
Expected: PASS. Then start the app if credentials are available and verify selecting voice guild does not reset text guild/channel and selecting text guild does not reset voice guild/channel.
|
||||
|
||||
## Task 5: Final Verification
|
||||
|
||||
**Files:**
|
||||
- No planned edits unless verification fails.
|
||||
|
||||
- [ ] **Step 1: Run lint**
|
||||
|
||||
Run: `pnpm run lint`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run typecheck**
|
||||
|
||||
Run: `pnpm run typecheck`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
Run: `pnpm run test`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run build**
|
||||
|
||||
Run: `pnpm run build`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Inspect status**
|
||||
|
||||
Run: `git status --short`
|
||||
|
||||
Expected: only intended files changed.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: config split is Task 1; capture/backlog filtering is Task 2; backend UI state split is Task 3; dashboard split is Task 4; verification is Task 5.
|
||||
- Placeholder scan: no TBD/TODO/fill-in steps remain.
|
||||
- Type consistency: split fields use `selectedVoiceGuild`, `selectedVoiceChannel`, `selectedTextGuild`, `selectedTextChannel` consistently.
|
||||
@@ -0,0 +1,488 @@
|
||||
# Vendor Selfbot Dependency Modernization 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:** Aggressively modernize the vendored `discord.js-selfbot-v13` dependency by replacing its legacy toolchain with Biome and auditing runtime dependencies without changing the public API used by the root app.
|
||||
|
||||
**Architecture:** The vendor submodule remains a CommonJS package exported from `vendor/discord.js-selfbot-v13/src/index.js`. Tooling moves to Biome plus TypeScript/tsd validation, while runtime dependencies are changed only after usage evidence from `src`, `typings`, config, and scripts. Root workspace resolution remains `workspace:*` and is validated from the root after vendor changes.
|
||||
|
||||
**Tech Stack:** Node.js >=20.18, pnpm workspaces, Biome, TypeScript, tsd, CommonJS, git submodule.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `vendor/discord.js-selfbot-v13/package.json` for scripts and dependencies.
|
||||
- Create: `vendor/discord.js-selfbot-v13/biome.json` for vendor-specific Biome scope.
|
||||
- Remove: `vendor/discord.js-selfbot-v13/.eslintrc.json`, `vendor/discord.js-selfbot-v13/.prettierrc.json`, `vendor/discord.js-selfbot-v13/tslint.json` after scripts no longer reference them.
|
||||
- Modify: `vendor/discord.js-selfbot-v13/tsconfig.json` only if modern TypeScript validation requires config compatibility.
|
||||
- Modify: `vendor/discord.js-selfbot-v13/src/**/*.js` only for required runtime dependency replacements or Biome-safe formatting fixes.
|
||||
- Modify: `vendor/discord.js-selfbot-v13/typings/**/*.d.ts` only for TypeScript/tsd compatibility.
|
||||
- Modify: `pnpm-lock.yaml` by running pnpm from the root, not by hand.
|
||||
- Do not modify root app source files unless validation proves a compatibility issue from the vendor API.
|
||||
|
||||
### Task 1: Capture baseline and dependency usage evidence
|
||||
|
||||
**Files:**
|
||||
- Read: `vendor/discord.js-selfbot-v13/package.json`
|
||||
- Read: `vendor/discord.js-selfbot-v13/src/**/*.js`
|
||||
- Read: `vendor/discord.js-selfbot-v13/typings/**/*.d.ts`
|
||||
|
||||
- [ ] **Step 1: Capture current vendor dependency lists**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node - <<'NODE'
|
||||
const pkg = require('./vendor/discord.js-selfbot-v13/package.json');
|
||||
console.log('dependencies');
|
||||
for (const name of Object.keys(pkg.dependencies || {}).sort()) console.log(`${name} ${pkg.dependencies[name]}`);
|
||||
console.log('devDependencies');
|
||||
for (const name of Object.keys(pkg.devDependencies || {}).sort()) console.log(`${name} ${pkg.devDependencies[name]}`);
|
||||
NODE
|
||||
```
|
||||
|
||||
Expected: prints the current runtime and dev dependency names and versions.
|
||||
|
||||
- [ ] **Step 2: Capture runtime usage map**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node - <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const root = 'vendor/discord.js-selfbot-v13';
|
||||
const deps = [
|
||||
'@discordjs/builders',
|
||||
'@discordjs/collection',
|
||||
'@sapphire/async-queue',
|
||||
'@sapphire/shapeshift',
|
||||
'discord-api-types',
|
||||
'fetch-cookie',
|
||||
'find-process',
|
||||
'otplib',
|
||||
'prism-media',
|
||||
'qrcode',
|
||||
'tough-cookie',
|
||||
'tree-kill',
|
||||
'undici',
|
||||
'werift-rtp',
|
||||
'ws',
|
||||
];
|
||||
const files = [];
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (/\.(js|ts|d\.ts|json)$/.test(full)) files.push(full);
|
||||
}
|
||||
}
|
||||
walk(path.join(root, 'src'));
|
||||
walk(path.join(root, 'typings'));
|
||||
for (const dep of deps) {
|
||||
const hits = [];
|
||||
for (const file of files) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
if (text.includes(`require('${dep}`) || text.includes(`require("${dep}`) || text.includes(`from '${dep}`) || text.includes(`from "${dep}`)) hits.push(file);
|
||||
}
|
||||
console.log(`${dep}: ${hits.length ? hits.join(', ') : 'UNUSED'}`);
|
||||
}
|
||||
NODE
|
||||
```
|
||||
|
||||
Expected usage classification based on current code:
|
||||
|
||||
```text
|
||||
@discordjs/builders: vendor/discord.js-selfbot-v13/src/util/Formatters.js, vendor/discord.js-selfbot-v13/src/managers/ApplicationCommandManager.js, vendor/discord.js-selfbot-v13/typings/index.d.ts
|
||||
@discordjs/collection: many src files and typings/index.d.ts
|
||||
@sapphire/async-queue: vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js
|
||||
@sapphire/shapeshift: vendor/discord.js-selfbot-v13/src/structures/interfaces/TextBasedChannel.js
|
||||
discord-api-types: vendor/discord.js-selfbot-v13/src/client/websocket/WebSocketManager.js, vendor/discord.js-selfbot-v13/typings/index.d.ts, vendor/discord.js-selfbot-v13/typings/rawDataTypes.d.ts
|
||||
fetch-cookie: vendor/discord.js-selfbot-v13/src/rest/RESTManager.js
|
||||
find-process: vendor/discord.js-selfbot-v13/src/client/voice/receiver/Recorder.js
|
||||
otplib: vendor/discord.js-selfbot-v13/src/client/Client.js
|
||||
prism-media: vendor/discord.js-selfbot-v13/src/client/voice/util/PlayInterface.js, vendor/discord.js-selfbot-v13/src/client/voice/player/MediaPlayer.js, vendor/discord.js-selfbot-v13/src/client/voice/receiver/Receiver.js
|
||||
qrcode: vendor/discord.js-selfbot-v13/src/util/RemoteAuth.js
|
||||
tough-cookie: vendor/discord.js-selfbot-v13/src/rest/RESTManager.js
|
||||
tree-kill: vendor/discord.js-selfbot-v13/src/client/voice/receiver/Recorder.js
|
||||
undici: vendor/discord.js-selfbot-v13/src/rest/APIRequest.js, vendor/discord.js-selfbot-v13/src/rest/RESTManager.js, vendor/discord.js-selfbot-v13/src/util/RemoteAuth.js, vendor/discord.js-selfbot-v13/src/util/Util.js, vendor/discord.js-selfbot-v13/src/util/DataResolver.js
|
||||
werift-rtp: vendor/discord.js-selfbot-v13/src/client/voice/receiver/PacketHandler.js, vendor/discord.js-selfbot-v13/src/client/voice/receiver/Recorder.js
|
||||
ws: vendor/discord.js-selfbot-v13/src/WebSocket.js, vendor/discord.js-selfbot-v13/src/util/RemoteAuth.js
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Confirm type assertion tests exist**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
find vendor/discord.js-selfbot-v13 -maxdepth 3 -type f -name '*.test-d.ts' -print
|
||||
```
|
||||
|
||||
Expected output includes:
|
||||
|
||||
```text
|
||||
vendor/discord.js-selfbot-v13/typings/index.test-d.ts
|
||||
```
|
||||
|
||||
Decision: keep `tsd` because `typings/index.test-d.ts` exists.
|
||||
|
||||
### Task 2: Replace vendor lint and format toolchain with Biome
|
||||
|
||||
**Files:**
|
||||
- Modify: `vendor/discord.js-selfbot-v13/package.json`
|
||||
- Create: `vendor/discord.js-selfbot-v13/biome.json`
|
||||
- Remove: `vendor/discord.js-selfbot-v13/.eslintrc.json`
|
||||
- Remove: `vendor/discord.js-selfbot-v13/.prettierrc.json`
|
||||
- Remove: `vendor/discord.js-selfbot-v13/tslint.json`
|
||||
|
||||
- [ ] **Step 1: Update vendor scripts and dev dependencies**
|
||||
|
||||
Edit `vendor/discord.js-selfbot-v13/package.json` so `scripts` becomes:
|
||||
|
||||
```json
|
||||
{
|
||||
"all": "npm run build && npm publish",
|
||||
"test": "npm run lint && npm run test:typescript && npm run docs:test",
|
||||
"fix:all": "npm run format",
|
||||
"test:typescript": "tsc --noEmit && tsd",
|
||||
"lint": "biome check . --diagnostic-level=error",
|
||||
"format": "biome format --write .",
|
||||
"docs": "docgen --source src --custom docs/index.yml --output docs/main.json",
|
||||
"docs:test": "docgen --source src --custom docs/index.yml",
|
||||
"build": "npm run format && npm run docs"
|
||||
}
|
||||
```
|
||||
|
||||
In the same file, remove these dev dependencies:
|
||||
|
||||
```json
|
||||
"dtslint": "^4.2.1",
|
||||
"eslint": "^8.39.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"prettier": "^2.8.8",
|
||||
"tslint": "^6.1.3"
|
||||
```
|
||||
|
||||
Add this dev dependency if it is not already present in the vendor package:
|
||||
|
||||
```json
|
||||
"@biomejs/biome": "latest"
|
||||
```
|
||||
|
||||
Keep these dev dependencies:
|
||||
|
||||
```json
|
||||
"@discordjs/docgen": "^0.11.1",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/ws": "^8.5.10",
|
||||
"patch-package": "^8.0.0",
|
||||
"tsd": "^0.32.0",
|
||||
"typescript": "^5.5.4"
|
||||
```
|
||||
|
||||
Expected: no package scripts reference `eslint`, `prettier`, `tslint`, or `dtslint`.
|
||||
|
||||
- [ ] **Step 2: Create vendor Biome config**
|
||||
|
||||
Create `vendor/discord.js-selfbot-v13/biome.json` with:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
|
||||
"files": {
|
||||
"includes": ["src/**/*.js", "typings/**/*.ts", "*.json"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": false,
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "warn"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Expected: vendor can run its own Biome config without relying on the root config.
|
||||
|
||||
- [ ] **Step 3: Remove legacy config files**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rm vendor/discord.js-selfbot-v13/.eslintrc.json vendor/discord.js-selfbot-v13/.prettierrc.json vendor/discord.js-selfbot-v13/tslint.json
|
||||
```
|
||||
|
||||
Expected: the files are removed because no script references those tools.
|
||||
|
||||
- [ ] **Step 4: Verify legacy tool references are gone from package scripts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node - <<'NODE'
|
||||
const pkg = require('./vendor/discord.js-selfbot-v13/package.json');
|
||||
const scripts = JSON.stringify(pkg.scripts || {});
|
||||
for (const tool of ['eslint', 'prettier', 'tslint', 'dtslint']) {
|
||||
if (scripts.includes(tool)) throw new Error(`legacy tool still referenced: ${tool}`);
|
||||
}
|
||||
console.log('legacy script references removed');
|
||||
NODE
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```text
|
||||
legacy script references removed
|
||||
```
|
||||
|
||||
### Task 3: Modernize runtime and dev dependency ranges with usage evidence
|
||||
|
||||
**Files:**
|
||||
- Modify: `vendor/discord.js-selfbot-v13/package.json`
|
||||
- Modify: `pnpm-lock.yaml` after root install
|
||||
|
||||
- [ ] **Step 1: Update used runtime dependencies to current compatible ranges**
|
||||
|
||||
Edit `vendor/discord.js-selfbot-v13/package.json` dependencies to these ranges unless a package manager reports a direct incompatibility during install:
|
||||
|
||||
```json
|
||||
{
|
||||
"@discordjs/builders": "^1.13.0",
|
||||
"@discordjs/collection": "^2.1.1",
|
||||
"@sapphire/async-queue": "^1.5.5",
|
||||
"@sapphire/shapeshift": "^4.0.0",
|
||||
"discord-api-types": "^0.38.38",
|
||||
"fetch-cookie": "^3.1.0",
|
||||
"find-process": "^2.0.0",
|
||||
"otplib": "^12.0.1",
|
||||
"prism-media": "^2.0.0-alpha.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"tough-cookie": "^5.1.2",
|
||||
"tree-kill": "^1.2.2",
|
||||
"undici": "^7.16.0",
|
||||
"werift-rtp": "^0.8.4",
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
```
|
||||
|
||||
Expected: no runtime dependency is removed yet because all are currently used by source or typings.
|
||||
|
||||
- [ ] **Step 2: Update vendor dev dependency ranges**
|
||||
|
||||
Edit `vendor/discord.js-selfbot-v13/package.json` devDependencies to:
|
||||
|
||||
```json
|
||||
{
|
||||
"@biomejs/biome": "latest",
|
||||
"@discordjs/docgen": "^0.11.1",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"tsd": "^0.33.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
```
|
||||
|
||||
Expected: legacy lint/format/type-lint packages are absent.
|
||||
|
||||
- [ ] **Step 3: Refresh root workspace lockfile**
|
||||
|
||||
Run from `/mnt/code/bete`:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Expected: install completes and `pnpm-lock.yaml` updates the vendor importer dependency ranges.
|
||||
|
||||
- [ ] **Step 4: Verify removed dev packages are no longer vendor dependencies**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node - <<'NODE'
|
||||
const pkg = require('./vendor/discord.js-selfbot-v13/package.json');
|
||||
const all = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
||||
for (const name of ['dtslint', 'eslint', 'eslint-config-prettier', 'eslint-plugin-import', 'eslint-plugin-prettier', 'prettier', 'tslint']) {
|
||||
if (name in all) throw new Error(`legacy package still present: ${name}`);
|
||||
}
|
||||
console.log('legacy packages removed');
|
||||
NODE
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```text
|
||||
legacy packages removed
|
||||
```
|
||||
|
||||
### Task 4: Run vendor validation and make Biome-safe fixes
|
||||
|
||||
**Files:**
|
||||
- Modify: `vendor/discord.js-selfbot-v13/src/**/*.js` only if Biome emits errors.
|
||||
- Modify: `vendor/discord.js-selfbot-v13/typings/**/*.d.ts` only if TypeScript or tsd emits errors.
|
||||
- Modify: `vendor/discord.js-selfbot-v13/biome.json` only if the configured scope is wrong.
|
||||
|
||||
- [ ] **Step 1: Run vendor Biome check**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm --filter discord.js-selfbot-v13 run lint
|
||||
```
|
||||
|
||||
Expected: either passes, or reports concrete Biome diagnostics in vendor files.
|
||||
|
||||
- [ ] **Step 2: Apply Biome formatting if lint reports formatting diagnostics**
|
||||
|
||||
Run only if Step 1 reports formatting diagnostics:
|
||||
|
||||
```bash
|
||||
pnpm --filter discord.js-selfbot-v13 run format
|
||||
pnpm --filter discord.js-selfbot-v13 run lint
|
||||
```
|
||||
|
||||
Expected: formatting diagnostics are fixed. If lint still reports correctness errors, fix only the reported vendor lines without changing behavior, then rerun lint.
|
||||
|
||||
- [ ] **Step 3: Run vendor TypeScript/type validation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm --filter discord.js-selfbot-v13 run test:typescript
|
||||
```
|
||||
|
||||
Expected: `tsc --noEmit && tsd` passes. If it fails due dependency type changes, fix typings or dependency ranges while preserving public API, then rerun.
|
||||
|
||||
- [ ] **Step 4: Run vendor test script**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm --filter discord.js-selfbot-v13 run test
|
||||
```
|
||||
|
||||
Expected: vendor test script passes. If `docs:test` fails due docgen compatibility unrelated to dependency modernization, record the error and run lint + `test:typescript` as the required validation gate.
|
||||
|
||||
### Task 5: Validate root workspace integration
|
||||
|
||||
**Files:**
|
||||
- Modify: `pnpm-lock.yaml` only via pnpm.
|
||||
- Read: root `package.json`, `src/**/*.ts`.
|
||||
|
||||
- [ ] **Step 1: Verify workspace dependency link**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm list discord.js-selfbot-v13 --depth 0
|
||||
```
|
||||
|
||||
Expected output includes:
|
||||
|
||||
```text
|
||||
discord.js-selfbot-v13 link:vendor/discord.js-selfbot-v13
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run root typecheck**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
Expected: TypeScript exits successfully.
|
||||
|
||||
- [ ] **Step 3: Run root lint**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm run lint
|
||||
```
|
||||
|
||||
Expected: Biome checks root files successfully. If it scans nested generated worktrees under `.claude/worktrees`, remove only session-generated agent worktrees after confirming they are not needed, then rerun lint.
|
||||
|
||||
- [ ] **Step 4: Run root import smoke check**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node - <<'NODE'
|
||||
const selfbot = require('discord.js-selfbot-v13');
|
||||
for (const key of ['Client', 'Collection', 'WebSocket']) {
|
||||
if (!(key in selfbot)) throw new Error(`missing export: ${key}`);
|
||||
}
|
||||
console.log('selfbot exports available');
|
||||
NODE
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```text
|
||||
selfbot exports available
|
||||
```
|
||||
|
||||
### Task 6: Review submodule and root diffs
|
||||
|
||||
**Files:**
|
||||
- Review: `vendor/discord.js-selfbot-v13/package.json`
|
||||
- Review: `vendor/discord.js-selfbot-v13/biome.json`
|
||||
- Review: removed legacy config files
|
||||
- Review: `pnpm-lock.yaml`
|
||||
- Review: root submodule gitlink
|
||||
|
||||
- [ ] **Step 1: Review vendor status**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git -C vendor/discord.js-selfbot-v13 status --short
|
||||
git -C vendor/discord.js-selfbot-v13 diff -- package.json biome.json tsconfig.json src typings .eslintrc.json .prettierrc.json tslint.json
|
||||
```
|
||||
|
||||
Expected: vendor changes are limited to toolchain config, package metadata, lock-relevant dependency ranges, and any validation-driven source/typing fixes.
|
||||
|
||||
- [ ] **Step 2: Review root status**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff -- package.json pnpm-workspace.yaml pnpm-lock.yaml .gitmodules docs/superpowers/specs/2026-05-15-vendor-selfbot-dependency-modernization-design.md docs/superpowers/plans/2026-05-15-vendor-selfbot-dependency-modernization.md
|
||||
git diff --submodule
|
||||
```
|
||||
|
||||
Expected: root changes include the existing submodule/workspace setup, this spec/plan, lockfile refresh, and the updated submodule gitlink. Existing unrelated `README.md` remains untouched.
|
||||
|
||||
- [ ] **Step 3: Do not push or commit without explicit user permission**
|
||||
|
||||
No commit, push, PR, or submodule remote update should run unless the user explicitly asks. If asked, commit inside the vendor submodule first, push that commit, then update the root submodule gitlink and commit root changes separately.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: the plan covers runtime dependency audit, Biome-only vendor toolchain, TypeScript/tsd validation, root install/typecheck/lint, and import smoke check.
|
||||
- Placeholder scan: no TBD/TODO placeholders remain.
|
||||
- Type consistency: all paths use `vendor/discord.js-selfbot-v13`, scripts use `biome`, `tsc`, and `tsd`, and the root dependency remains `workspace:*`.
|
||||
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,264 @@
|
||||
# AI Message Flow + React Dashboard Redesign
|
||||
|
||||
## Goal
|
||||
|
||||
Rebuild the moderation watcher flow so message capture is fast, AI analysis is contextual and reliable, APIs are split by concern, and the dashboard is maintainable. The implementation may fully replace the current static frontend and reorganize backend modules, while preserving existing voice functionality.
|
||||
|
||||
## Current Problems
|
||||
|
||||
- Message capture, AI queuing, DB access, and WebSocket broadcasting are tightly coupled.
|
||||
- AI analysis batches depend on array ordering, which can mismatch results when the model returns malformed or partial JSON.
|
||||
- Pending analysis is polled globally, not grouped by conversation, so context is weak and request efficiency is inconsistent.
|
||||
- `/api/messages` mixes text/image concerns and uses offset pagination, which gets slower and less stable as rows grow.
|
||||
- Frontend is a large static HTML file with inline state, API, WebSocket, rendering, and audio code.
|
||||
- WebSocket broadcast uses untyped `globalThis` hooks across modules.
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
### Message ingestion
|
||||
|
||||
`messageCapture` becomes a narrow ingestion layer:
|
||||
|
||||
1. Filter Discord events by guild and author.
|
||||
2. Normalize message payload and metadata.
|
||||
3. Upsert message/attachment records.
|
||||
4. Set or reset `ai_status` to `pending` for new or edited text.
|
||||
5. Emit typed domain events for WebSocket broadcasting and analysis queueing.
|
||||
|
||||
It should not build prompts, manage AI batches, or query unrelated DB state.
|
||||
|
||||
### Store/repository layer
|
||||
|
||||
`messageStore` becomes the single message/attachment query boundary. It should expose focused functions:
|
||||
|
||||
- `upsertMessage`
|
||||
- `markMessageEdited`
|
||||
- `markMessageDeleted`
|
||||
- `listMessages`
|
||||
- `listReviewMessages`
|
||||
- `getConversationContext`
|
||||
- `claimPendingMessagesForChannel`
|
||||
- `saveAnalysisResults`
|
||||
- `insertAttachments`
|
||||
|
||||
Queries should use cursor pagination based on `(created_at, id)` instead of offset pagination. Common filters: `guildId`, `channelId`, `threadId`, `status`, `userId`, `q`, `limit`, `cursor`.
|
||||
|
||||
### Analysis queue
|
||||
|
||||
Add an `analysisQueue` module. It owns async AI processing and keeps capture fast.
|
||||
|
||||
- Queue key: `thread_id ?? channel_id`.
|
||||
- Debounce: 1–3 seconds per key to group nearby messages.
|
||||
- Batch pending messages by conversation key and token budget.
|
||||
- Only one or a small fixed number of active LLM requests.
|
||||
- Backlog worker feeds the same queue; no separate analysis path.
|
||||
- Edits reset a message to `pending` and enqueue its conversation key.
|
||||
|
||||
If the process restarts, pending rows are recovered by a periodic lightweight scanner grouped by conversation key.
|
||||
|
||||
### Conversation context builder
|
||||
|
||||
Add `conversationContext` module:
|
||||
|
||||
- Input: conversation key + target pending messages.
|
||||
- Fetch context before the first target message, normally 20 prior messages.
|
||||
- Include target messages and close neighboring messages when within budget.
|
||||
- Mark target messages explicitly in the prompt.
|
||||
- Keep context scoped to one channel/thread to avoid irrelevant noise.
|
||||
|
||||
### LLM moderation client
|
||||
|
||||
Add `llmModerationClient` module:
|
||||
|
||||
- Own request shape, timeout, retry, JSON extraction, and validation.
|
||||
- Prompt returns JSON keyed by `message_id`, not positional arrays.
|
||||
- Expected response shape:
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"message_id": "string",
|
||||
"status": "clean|warn|flagged",
|
||||
"flags": ["string"],
|
||||
"score": 0.0,
|
||||
"analysis": "Bahasa Indonesia summary + reason + suggested action"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- Reject unknown IDs, invalid statuses, invalid scores, and missing target IDs.
|
||||
- On partial model failure, retry once with smaller batch. If still invalid, mark only affected target messages as `error`.
|
||||
- Store raw batch request/response in one run record if the DB migration is included; otherwise store compact raw metadata per message.
|
||||
|
||||
## API Design
|
||||
|
||||
Split API by use case so reads remain fast and obvious.
|
||||
|
||||
### Message read APIs
|
||||
|
||||
- `GET /api/messages`
|
||||
- Query: `guildId`, `channelId`, `threadId`, `cursor`, `limit`, `status`, `userId`, `q`.
|
||||
- Returns: `{ data, nextCursor }`.
|
||||
- Uses indexed cursor pagination.
|
||||
|
||||
- `GET /api/messages/:id`
|
||||
- Returns one message with attachments and AI analysis.
|
||||
|
||||
- `GET /api/review`
|
||||
- Query: `guildId`, optional `channelId`, `status=warn,flagged,error`, `cursor`, `limit`.
|
||||
- Optimized for moderator review panel.
|
||||
|
||||
- `GET /api/attachments`
|
||||
- Query: `channelId`, `threadId`, `cursor`, `limit`, `type`.
|
||||
- Replaces image mode inside `/api/messages`.
|
||||
|
||||
### Analysis APIs
|
||||
|
||||
- `POST /api/messages/:id/reanalyze`
|
||||
- Sets message to `pending` and queues its conversation.
|
||||
- Returns `202 Accepted` with current message status.
|
||||
|
||||
- `POST /api/analysis/requeue-pending`
|
||||
- Admin/manual recovery endpoint for pending/error rows.
|
||||
- Returns count queued.
|
||||
|
||||
- `GET /api/analysis/status`
|
||||
- Returns queue depth, active requests, last error, and pending counts.
|
||||
|
||||
### Discord sync APIs
|
||||
|
||||
- `POST /api/backlog-sync`
|
||||
- Stays async-friendly: starts sync for guild/channel/thread and returns `202` with a job id or immediate summary if small.
|
||||
- Sync inserts messages, then queues analysis through the same `analysisQueue`.
|
||||
|
||||
### Voice/control APIs
|
||||
|
||||
Keep existing voice APIs working, but move route registration into route modules:
|
||||
|
||||
- `routes/voiceRoutes.ts`
|
||||
- `routes/messageRoutes.ts`
|
||||
- `routes/analysisRoutes.ts`
|
||||
- `routes/syncRoutes.ts`
|
||||
- `routes/uiStateRoutes.ts`
|
||||
|
||||
`webserver.ts` should only create Express/WS server, install middleware, register routes, and start listening.
|
||||
|
||||
## WebSocket Design
|
||||
|
||||
Replace ad-hoc globals with a typed broadcaster module.
|
||||
|
||||
Events:
|
||||
|
||||
- `ui_state`
|
||||
- `user_state`
|
||||
- `message_created`
|
||||
- `message_updated`
|
||||
- `message_deleted`
|
||||
- `message_analyzed`
|
||||
- `attachment_created`
|
||||
- `analysis_queue_status`
|
||||
|
||||
Backend modules call broadcaster functions; they do not touch WebSocket clients directly.
|
||||
|
||||
## Database Changes
|
||||
|
||||
Add or verify indexes:
|
||||
|
||||
- messages `(channel_id, created_at, id)`
|
||||
- messages `(thread_id, created_at, id)`
|
||||
- messages `(ai_status, created_at, id)`
|
||||
- messages `(guild_id, ai_status, created_at, id)`
|
||||
- attachments `(channel_id, created_at, id)`
|
||||
- attachments `(thread_id, created_at, id)`
|
||||
|
||||
Optional but preferred:
|
||||
|
||||
- `ai_analysis_runs` table:
|
||||
- `id`
|
||||
- `conversation_key`
|
||||
- `target_message_ids`
|
||||
- `model`
|
||||
- `request_tokens_estimate`
|
||||
- `response_raw`
|
||||
- `status`
|
||||
- `error`
|
||||
- `created_at`
|
||||
- `completed_at`
|
||||
|
||||
This avoids duplicating large raw LLM responses into every message row.
|
||||
|
||||
## React/Vite Frontend
|
||||
|
||||
Replace static inline dashboard code with a TypeScript React app.
|
||||
|
||||
Suggested structure:
|
||||
|
||||
- `frontend/src/api/` — typed REST clients
|
||||
- `frontend/src/ws/` — WebSocket client and event types
|
||||
- `frontend/src/state/` — small hooks for selected guild/channel, messages, review queue, voice state
|
||||
- `frontend/src/components/voice/` — existing voice control/audio components
|
||||
- `frontend/src/components/messages/` — feed, message card, filters, detail drawer
|
||||
- `frontend/src/components/review/` — needs-review list and analysis status
|
||||
- `frontend/src/components/layout/` — shell/sidebar/status cards
|
||||
|
||||
UI layout:
|
||||
|
||||
- Left sidebar: guild, voice channel, text channel/thread, connection state.
|
||||
- Main area: message feed with filters and load-more cursor pagination.
|
||||
- Right panel: review queue for `warn`, `flagged`, and `error` messages.
|
||||
- Detail drawer/modal: message metadata, attachments, AI rationale, raw flags, reanalyze action.
|
||||
|
||||
Voice features stay functionally equivalent. Audio capture/playback code can be moved into React hooks but should not be behaviorally rewritten unless needed.
|
||||
|
||||
Build integration:
|
||||
|
||||
- Add Vite dev/build scripts.
|
||||
- Express serves the built app from a stable public directory in production.
|
||||
- During development, either run Vite separately or proxy API/WS to Express.
|
||||
|
||||
## Performance Rules
|
||||
|
||||
- Message capture must not wait on AI.
|
||||
- Read APIs use cursor pagination and indexes.
|
||||
- AI batches are bounded by token estimate and message count.
|
||||
- UI fetches initial pages, then patches via WebSocket.
|
||||
- Backlog sync should not block dashboard interactions.
|
||||
- Avoid storing full raw LLM response per message when a batch table is available.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Capture errors log and do not crash Discord client event handlers.
|
||||
- AI request failures mark target messages `error` with a short reason.
|
||||
- Invalid LLM JSON triggers retry/split before marking errors.
|
||||
- API validation returns 400 with structured error code.
|
||||
- WebSocket reconnect logic stays client-side.
|
||||
- Manual reanalysis provides recovery for bad AI results.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend:
|
||||
|
||||
- Unit tests for conversation context selection.
|
||||
- Unit tests for LLM response parser and validation.
|
||||
- Unit tests for queue batching/debounce behavior.
|
||||
- Integration tests for message cursor pagination and review filters.
|
||||
- Existing voice tests remain unchanged.
|
||||
|
||||
Frontend:
|
||||
|
||||
- Typecheck and Vite build.
|
||||
- Component-level smoke tests may be added if test tooling is already practical.
|
||||
- Manual browser verification: channel select, message feed, review panel, WebSocket updates, reanalyze action, voice controls.
|
||||
|
||||
## Implementation Scope
|
||||
|
||||
This is a full redesign of the message/AI/dashboard path. Voice recording and live audio behavior should be preserved unless a change is required to integrate the React dashboard.
|
||||
|
||||
Implementation should proceed incrementally:
|
||||
|
||||
1. Backend boundaries and typed broadcaster.
|
||||
2. Store/query improvements and indexes.
|
||||
3. Analysis queue/context/client rewrite.
|
||||
4. Split API routes.
|
||||
5. React/Vite dashboard.
|
||||
6. Verification and cleanup of old static dashboard code.
|
||||
110
docs/superpowers/specs/2026-05-15-media-music-phase-1-design.md
Normal file
110
docs/superpowers/specs/2026-05-15-media-music-phase-1-design.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Media Music Phase 1 Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add a first media playback phase focused on play music: users can queue, play, skip, and stop audio sources from the dashboard while preserving the existing Discord voice recorder, browser microphone transmit, and moderation capture flows.
|
||||
|
||||
## Scope
|
||||
|
||||
Phase 1 implements audio-only playback and queue control. Share screen/video streaming is intentionally reserved for phase 2, but the controller shape should leave room for a later `screen` mode using the already vendored `@dank074/discord-video-stream` APIs seen in `MythEclipse/StreamBot`.
|
||||
|
||||
## Recommended Architecture
|
||||
|
||||
Create a small media subsystem under `src/media/`:
|
||||
|
||||
- `mediaTypes.ts` defines `MediaMode`, `MediaQueueItem`, `MediaState`, and request/response types.
|
||||
- `mediaQueue.ts` owns in-memory queue operations: add, current, next, remove current, clear, snapshot.
|
||||
- `mediaResolver.ts` resolves initial supported sources. Phase 1 should support direct HTTP(S) URLs and local file paths. YouTube/search can be added later because it requires adding or wrapping yt-dlp behavior.
|
||||
- `musicPlayer.ts` converts a media source to Ogg Opus using ffmpeg and feeds the existing `discordPlayer.playStream()`.
|
||||
- `mediaController.ts` coordinates queue state, voice connection assumptions, play/skip/stop, and WebSocket broadcast state.
|
||||
|
||||
The existing `VoiceController` remains the owner of joining/leaving voice channels. Phase 1 does not create a second voice connection path. Music playback requires the bot to already be connected through the existing voice UI or `/api/connect`; otherwise the media route returns `409 VOICE_NOT_CONNECTED`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. Browser submits a source to `/api/media/queue` with `{ source }`.
|
||||
2. `mediaResolver` validates and resolves the source into `{ source, title, kind }`.
|
||||
3. `mediaQueue` appends a `MediaQueueItem`.
|
||||
4. If no item is playing, `mediaController` starts playback of the current queue item.
|
||||
5. `musicPlayer` spawns ffmpeg and outputs Ogg Opus to `discordPlayer.playStream()`.
|
||||
6. When playback finishes, the controller removes the completed item and starts the next item.
|
||||
7. State changes broadcast over the existing moderation broadcaster as a JSON WebSocket event, or via a small media broadcaster wrapper if that keeps types cleaner.
|
||||
|
||||
## API Design
|
||||
|
||||
Add `src/routes/mediaRoutes.ts` mounted under `/api`:
|
||||
|
||||
- `GET /api/media/status` returns `{ playing, current, queue }`.
|
||||
- `POST /api/media/queue` accepts `{ source: string }`, queues it, and returns the updated state.
|
||||
- `POST /api/media/skip` skips current item and starts the next if present.
|
||||
- `POST /api/media/stop` stops playback and clears the queue.
|
||||
|
||||
All routes should use `AppError` for boundary validation. Empty source returns `400 MISSING_MEDIA_SOURCE`. No voice connection returns `409 VOICE_NOT_CONNECTED`.
|
||||
|
||||
## Dashboard Design
|
||||
|
||||
Add a compact Media card to the existing voice tab for phase 1:
|
||||
|
||||
- Source input: URL or local path.
|
||||
- Buttons: Queue/Play, Skip, Stop.
|
||||
- Current item label and queue list.
|
||||
|
||||
Do not add a separate full media tab yet. The voice tab already owns voice channel selection and connection state, so colocating music controls there reduces user confusion.
|
||||
|
||||
## Playback Details
|
||||
|
||||
Use ffmpeg directly or the existing `src/audio/ffmpegProcess.ts` helper if it already fits. The target stream should be Ogg Opus because `DiscordPlayer.playStream()` currently expects `StreamType.OggOpus`.
|
||||
|
||||
Recommended ffmpeg output shape:
|
||||
|
||||
- Input: local file or HTTP(S) URL.
|
||||
- Output format: `ogg`.
|
||||
- Audio codec: `libopus`.
|
||||
- Sample rate: `48000`.
|
||||
- Channels: `2`.
|
||||
|
||||
The controller owns an `AbortController` or child process handle so skip/stop can terminate ffmpeg. Stop must also call `discordPlayer.stop()` so the audio player releases the current resource.
|
||||
|
||||
## Concurrency Rules
|
||||
|
||||
- Only one media item plays at a time.
|
||||
- Browser microphone transmit and music playback both use `discordPlayer`; phase 1 should disable music start while `isStreaming` is true, or stop browser transmit before playback. Prefer returning `409 BROWSER_STREAM_ACTIVE` to avoid surprising the user.
|
||||
- Voice recording can continue while music plays because recording uses the receiver pipeline and music uses the player pipeline.
|
||||
- Skip is serialized: concurrent skip calls should return the same resulting state or reject with `409 MEDIA_SKIP_IN_PROGRESS`.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Unsupported source format: `400 UNSUPPORTED_MEDIA_SOURCE`.
|
||||
- ffmpeg spawn failure: current item becomes failed, playback advances to the next queued item if present.
|
||||
- ffmpeg runtime failure: log stderr summary, mark item failed, advance queue.
|
||||
- Stop is idempotent: stopping while idle returns current idle state.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests should cover:
|
||||
|
||||
- Queue add/next/remove/clear behavior.
|
||||
- Resolver accepts HTTP(S) URLs and existing local paths, rejects empty/unsupported input.
|
||||
- Controller rejects playback when voice is not connected.
|
||||
- Controller starts next item after completion.
|
||||
- Skip aborts current playback and advances queue.
|
||||
- Routes validate payloads and call controller methods.
|
||||
|
||||
Manual verification should cover:
|
||||
|
||||
- Connect to a voice channel, queue a short audio URL or local file, hear playback in Discord.
|
||||
- Queue two items, confirm automatic advance.
|
||||
- Skip moves to the next item.
|
||||
- Stop clears playback and queue.
|
||||
- Existing voice recording and text moderation still work after media playback.
|
||||
|
||||
## Phase 2 Compatibility
|
||||
|
||||
Phase 2 can add `MediaMode = "screen"` and a `screenSharePlayer.ts` using StreamBot's pattern:
|
||||
|
||||
- `new Streamer(client)`
|
||||
- `streamer.joinVoice(guildId, channelId)` only if phase 2 decides to own its own connection path
|
||||
- `prepareStream(source, videoOptions, signal)`
|
||||
- `playStream(output, streamer, { type: "go-live" }, signal)`
|
||||
|
||||
Phase 1 should not instantiate `Streamer`; it should only reserve type and controller seams so adding screen share later does not rewrite queue/status APIs.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Selfbot Performance and Feature Optimization Design
|
||||
|
||||
## Goal
|
||||
|
||||
Improve `vendor/discord.js-selfbot-v13` and the app's Discord client setup for lower memory use, more stable REST behavior, lower voice hot-path allocation, and better observability while preserving the existing public API used by the bot.
|
||||
|
||||
## Scope
|
||||
|
||||
This is an aggressive optimization pass. It includes app-level client configuration plus internal vendor patches in REST, voice, and gateway queue handling. Changes must remain compatible with existing imports from `discord.js-selfbot-v13` and the current moderation/voice flows.
|
||||
|
||||
## App Runtime Configuration
|
||||
|
||||
`src/index.ts` will instantiate `Client` with explicit low-memory options instead of using `new Client()` defaults. Message cache will be reduced or disabled because captured messages are persisted to the database. Sweepers will remove old message/thread cache entries. REST retry/timeouts will remain conservative to avoid bursty backlog sync behavior.
|
||||
|
||||
## Vendor REST Improvements
|
||||
|
||||
`src/rest/APIRequest.js` currently uses a module-level Undici dispatcher and rebuilds expensive headers per request. The dispatcher will become per REST manager/client so proxy or client-specific settings cannot leak across clients. The `x-super-properties` header will be cached and reused while client properties remain unchanged. `RequestHandler` will add exponential backoff with jitter for network aborts and 5xx retries.
|
||||
|
||||
## Vendor Voice Improvements
|
||||
|
||||
`PacketHandler` will clean all speaking timeouts during stream destruction. Voice stream cleanup will clear audio and video stream maps reliably. RTP/decrypt hot-path allocation will be reduced where possible without changing emitted packet payloads or stream API behavior.
|
||||
|
||||
## Vendor Gateway Queue Improvements
|
||||
|
||||
`WebSocketShard` will replace repeated `Array.shift()` dequeue with a cursor-backed queue to avoid O(n) work under high gateway send volume. `send(data, important)` behavior will remain compatible, including priority insertion.
|
||||
|
||||
## Observability
|
||||
|
||||
Vendor internals will emit or debug useful operational data where it does not create noisy logs by default: REST retry/backoff attempts, voice stream cleanup counts, and gateway queue size/rate-limit state. The app can wire these later if needed.
|
||||
|
||||
## Error Handling
|
||||
|
||||
REST retry backoff must not bypass existing rate limit handling. Captcha and MFA retry paths keep their current behavior. Voice cleanup must ignore already-closed streams and never throw during disconnect. Gateway queue changes must clear queued state on destroy exactly as before.
|
||||
|
||||
## Testing
|
||||
|
||||
Run `pnpm run lint`, `pnpm run typecheck`, and `pnpm run test`. If runtime Discord login/voice testing cannot be performed in this environment, report that limitation explicitly and identify the manual test path: login, message capture, backlog sync, voice connect, voice record, disconnect/reconnect.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Selfbot Workspace Submodule Design
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the npm-resolved `discord.js-selfbot-v13` dependency with a custom repository checked into this project as a git submodule and consumed through pnpm workspace resolution.
|
||||
|
||||
## Approach
|
||||
|
||||
Use `vendor/discord.js-selfbot-v13` as the submodule path. Initialize it from `https://github.com/aiko-chan-ai/discord.js-selfbot-v13.git`, then change the submodule repository `origin` remote to `ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git`.
|
||||
|
||||
Configure pnpm workspaces so the root project and the vendored package are both workspace packages. Change the root dependency from the npm version range to `workspace:*`, forcing pnpm to resolve `discord.js-selfbot-v13` from the submodule package.
|
||||
|
||||
## Files to Change
|
||||
|
||||
- `.gitmodules`: track the new submodule path and URL.
|
||||
- `pnpm-workspace.yaml`: include the root package and `vendor/discord.js-selfbot-v13`.
|
||||
- `package.json`: change `discord.js-selfbot-v13` to `workspace:*`.
|
||||
- `pnpm-lock.yaml`: refresh dependency resolution after the workspace change.
|
||||
|
||||
## Validation
|
||||
|
||||
After the submodule and dependency changes, run `pnpm install` to update the workspace lockfile and links, then run `pnpm run typecheck` to confirm the app still resolves the selfbot package.
|
||||
|
||||
If the vendored package requires a build step before TypeScript can resolve it, use that package's own scripts and rerun root validation.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Vendor Selfbot Dependency Modernization Design
|
||||
|
||||
## Goal
|
||||
|
||||
Modernize `/mnt/code/bete/vendor/discord.js-selfbot-v13` aggressively by auditing runtime dependencies and replacing the legacy development toolchain with Biome, matching the root project style.
|
||||
|
||||
## Scope
|
||||
|
||||
This work targets the vendored `discord.js-selfbot-v13` submodule only, plus root lockfile/workspace updates required for the root app to consume it. The root app behavior and public import surface should remain compatible with the existing `discord.js-selfbot-v13` API.
|
||||
|
||||
## Approach
|
||||
|
||||
Audit all vendor `dependencies` and `devDependencies` against actual usage in `src`, `typings`, config files, and package scripts. Classify each package as keep, upgrade, remove, or replace. Apply changes aggressively, but only when usage evidence supports the change.
|
||||
|
||||
Replace the vendor's ESLint, Prettier, TSLint, and dtslint-based workflow with Biome. Keep TypeScript validation. Keep `tsd` only if the vendor has type assertion tests that `tsc --noEmit` cannot cover.
|
||||
|
||||
## Toolchain Design
|
||||
|
||||
Vendor scripts should use Biome for linting and formatting:
|
||||
|
||||
- `lint`: `biome check . --diagnostic-level=error`
|
||||
- `format`: `biome format --write .`
|
||||
- `test:typescript`: `tsc --noEmit` plus `tsd` only if type assertion tests exist
|
||||
- `test`: run lint and TypeScript validation
|
||||
|
||||
Remove deprecated or redundant dev dependencies after scripts no longer reference them:
|
||||
|
||||
- `eslint`
|
||||
- `eslint-config-prettier`
|
||||
- `eslint-plugin-import`
|
||||
- `eslint-plugin-prettier`
|
||||
- `prettier`
|
||||
- `tslint`
|
||||
- `dtslint`
|
||||
|
||||
Add `@biomejs/biome` to the vendor dev dependencies unless the workspace can reliably use the root Biome package for the vendor scripts.
|
||||
|
||||
## Runtime Dependency Design
|
||||
|
||||
Runtime dependencies are reviewed one by one. Candidate packages include:
|
||||
|
||||
- `find-process`
|
||||
- `tree-kill`
|
||||
- `prism-media`
|
||||
- `werift-rtp`
|
||||
- `fetch-cookie`
|
||||
- `tough-cookie`
|
||||
- `qrcode`
|
||||
- `otplib`
|
||||
- `ws`
|
||||
- `undici`
|
||||
- `discord-api-types`
|
||||
- `@discordjs/builders`
|
||||
- `@discordjs/collection`
|
||||
- `@sapphire/async-queue`
|
||||
- `@sapphire/shapeshift`
|
||||
|
||||
For each dependency, search source usage before changing it. Remove unused packages. Upgrade packages that remain used. Replace packages when Node 20+ or a smaller maintained package covers the same use case without changing public behavior.
|
||||
|
||||
## Validation
|
||||
|
||||
Validation must run in both vendor and root contexts:
|
||||
|
||||
1. Vendor dependency install/update.
|
||||
2. Vendor lint with Biome.
|
||||
3. Vendor TypeScript/type validation.
|
||||
4. Root `pnpm install` to refresh workspace lockfile.
|
||||
5. Root `pnpm run typecheck`.
|
||||
6. Root `pnpm run lint`.
|
||||
7. Import smoke check from the root app to ensure `discord.js-selfbot-v13` still resolves through the workspace link.
|
||||
|
||||
## Stop Rules
|
||||
|
||||
Stop and ask before making a change that would intentionally alter the public `discord.js-selfbot-v13` API, require ESM-only migration for the library entrypoint, or remove a runtime feature that the root app could use.
|
||||
|
||||
If a dependency upgrade requires broad internal rewrites, document the blocker and present options instead of forcing a risky migration.
|
||||
@@ -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.
|
||||
24
drizzle.config.ts
Normal file
24
drizzle.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
const databaseType = process.env.DATABASE_TYPE || "sqlite";
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/database/schema.ts",
|
||||
out: "./drizzle/migrations",
|
||||
dialect: databaseType === "postgres" ? "postgresql" : "sqlite",
|
||||
dbCredentials:
|
||||
databaseType === "postgres"
|
||||
? databaseUrl
|
||||
? { url: databaseUrl }
|
||||
: {
|
||||
host: process.env.POSTGRES_HOST || "localhost",
|
||||
port: parseInt(process.env.POSTGRES_PORT || "5432", 10),
|
||||
user: process.env.POSTGRES_USER || "postgres",
|
||||
password: process.env.POSTGRES_PASSWORD || "",
|
||||
database: process.env.POSTGRES_DB || "moderation_bot",
|
||||
}
|
||||
: {
|
||||
url: "file:./.muxer-queue.db",
|
||||
},
|
||||
});
|
||||
69
drizzle/migrations/0000_rare_kitty_pryde.sql
Normal file
69
drizzle/migrations/0000_rare_kitty_pryde.sql
Normal file
@@ -0,0 +1,69 @@
|
||||
CREATE TABLE "attachments" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"message_id" text NOT NULL,
|
||||
"guild_id" text NOT NULL,
|
||||
"channel_id" text NOT NULL,
|
||||
"thread_id" text,
|
||||
"user_id" text NOT NULL,
|
||||
"filename" text NOT NULL,
|
||||
"size" integer NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"discord_url" text NOT NULL,
|
||||
"uploaded_url" text,
|
||||
"upload_status" text DEFAULT 'pending' NOT NULL,
|
||||
"upload_error" text,
|
||||
"created_at" bigint NOT NULL,
|
||||
"uploaded_at" bigint
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "messages" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"guild_id" text NOT NULL,
|
||||
"channel_id" text NOT NULL,
|
||||
"thread_id" text,
|
||||
"user_id" text NOT NULL,
|
||||
"username" text NOT NULL,
|
||||
"avatar_url" text,
|
||||
"content" text NOT NULL,
|
||||
"edited_content" text,
|
||||
"created_at" bigint NOT NULL,
|
||||
"edited_at" bigint,
|
||||
"deleted_at" bigint,
|
||||
"type" text DEFAULT 'text' NOT NULL,
|
||||
"metadata" text,
|
||||
"ai_status" text DEFAULT 'pending' NOT NULL,
|
||||
"ai_moderation_flags" text,
|
||||
"ai_moderation_score" real,
|
||||
"ai_moderation_raw" text,
|
||||
"ai_analysis" text,
|
||||
"ai_analyzed_at" bigint,
|
||||
"ai_error" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "muxer_jobs" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"data" text NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"attempts" integer DEFAULT 0 NOT NULL,
|
||||
"maxAttempts" integer DEFAULT 3 NOT NULL,
|
||||
"createdAt" bigint NOT NULL,
|
||||
"updatedAt" bigint NOT NULL,
|
||||
"error" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ui_state" (
|
||||
"key" text PRIMARY KEY NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"updated_at" bigint NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "attachments" ADD CONSTRAINT "fk_attachments_message_id" FOREIGN KEY ("message_id") REFERENCES "public"."messages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_attachments_channel" ON "attachments" USING btree ("channel_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_attachments_message" ON "attachments" USING btree ("message_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_attachments_status" ON "attachments" USING btree ("upload_status");--> statement-breakpoint
|
||||
CREATE INDEX "idx_messages_channel" ON "messages" USING btree ("channel_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_messages_user" ON "messages" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_messages_created" ON "messages" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_messages_thread" ON "messages" USING btree ("thread_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_muxer_jobs_status" ON "muxer_jobs" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "idx_muxer_jobs_createdAt" ON "muxer_jobs" USING btree ("createdAt");
|
||||
22
drizzle/migrations/0001_curious_zodiak.sql
Normal file
22
drizzle/migrations/0001_curious_zodiak.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE "ai_analysis_runs" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"conversation_key" text NOT NULL,
|
||||
"target_message_ids" text NOT NULL,
|
||||
"model" text NOT NULL,
|
||||
"request_tokens_estimate" integer,
|
||||
"response_raw" text,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"error" text,
|
||||
"created_at" bigint NOT NULL,
|
||||
"completed_at" bigint
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "idx_ai_analysis_runs_conversation_key" ON "ai_analysis_runs" ("conversation_key");--> statement-breakpoint
|
||||
CREATE INDEX "idx_ai_analysis_runs_status" ON "ai_analysis_runs" ("status");--> statement-breakpoint
|
||||
CREATE INDEX "idx_ai_analysis_runs_created_at" ON "ai_analysis_runs" ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_attachments_channel_created" ON "attachments" ("channel_id","created_at","id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_attachments_thread_created" ON "attachments" ("thread_id","created_at","id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_messages_channel_created" ON "messages" ("channel_id","created_at","id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_messages_thread_created" ON "messages" ("thread_id","created_at","id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_messages_ai_status_created" ON "messages" ("ai_status","created_at","id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_messages_guild_ai_status_created" ON "messages" ("guild_id","ai_status","created_at","id");
|
||||
511
drizzle/migrations/meta/0000_snapshot.json
Normal file
511
drizzle/migrations/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,511 @@
|
||||
{
|
||||
"id": "2b9e2347-dd99-4bf8-bbcb-f407af29ca83",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.attachments": {
|
||||
"name": "attachments",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"message_id": {
|
||||
"name": "message_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"guild_id": {
|
||||
"name": "guild_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"channel_id": {
|
||||
"name": "channel_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"thread_id": {
|
||||
"name": "thread_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"discord_url": {
|
||||
"name": "discord_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"uploaded_url": {
|
||||
"name": "uploaded_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"upload_status": {
|
||||
"name": "upload_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"upload_error": {
|
||||
"name": "upload_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"uploaded_at": {
|
||||
"name": "uploaded_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_attachments_channel": {
|
||||
"name": "idx_attachments_channel",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "channel_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_attachments_message": {
|
||||
"name": "idx_attachments_message",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "message_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_attachments_status": {
|
||||
"name": "idx_attachments_status",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "upload_status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"fk_attachments_message_id": {
|
||||
"name": "fk_attachments_message_id",
|
||||
"tableFrom": "attachments",
|
||||
"tableTo": "messages",
|
||||
"columnsFrom": [
|
||||
"message_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.messages": {
|
||||
"name": "messages",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"guild_id": {
|
||||
"name": "guild_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"channel_id": {
|
||||
"name": "channel_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"thread_id": {
|
||||
"name": "thread_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"edited_content": {
|
||||
"name": "edited_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"edited_at": {
|
||||
"name": "edited_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'text'"
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_status": {
|
||||
"name": "ai_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"ai_moderation_flags": {
|
||||
"name": "ai_moderation_flags",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_moderation_score": {
|
||||
"name": "ai_moderation_score",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_moderation_raw": {
|
||||
"name": "ai_moderation_raw",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_analysis": {
|
||||
"name": "ai_analysis",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_analyzed_at": {
|
||||
"name": "ai_analyzed_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_error": {
|
||||
"name": "ai_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_messages_channel": {
|
||||
"name": "idx_messages_channel",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "channel_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_user": {
|
||||
"name": "idx_messages_user",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_created": {
|
||||
"name": "idx_messages_created",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_thread": {
|
||||
"name": "idx_messages_thread",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "thread_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.muxer_jobs": {
|
||||
"name": "muxer_jobs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"attempts": {
|
||||
"name": "attempts",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"maxAttempts": {
|
||||
"name": "maxAttempts",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 3
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_muxer_jobs_status": {
|
||||
"name": "idx_muxer_jobs_status",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_muxer_jobs_createdAt": {
|
||||
"name": "idx_muxer_jobs_createdAt",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "createdAt",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ui_state": {
|
||||
"name": "ui_state",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
799
drizzle/migrations/meta/0001_snapshot.json
Normal file
799
drizzle/migrations/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,799 @@
|
||||
{
|
||||
"id": "79348c3a-b314-4bfd-88f3-7ddfbea4427e",
|
||||
"prevId": "2b9e2347-dd99-4bf8-bbcb-f407af29ca83",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.ai_analysis_runs": {
|
||||
"name": "ai_analysis_runs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"conversation_key": {
|
||||
"name": "conversation_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"target_message_ids": {
|
||||
"name": "target_message_ids",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"request_tokens_estimate": {
|
||||
"name": "request_tokens_estimate",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"response_raw": {
|
||||
"name": "response_raw",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_ai_analysis_runs_conversation_key": {
|
||||
"name": "idx_ai_analysis_runs_conversation_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "conversation_key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_ai_analysis_runs_status": {
|
||||
"name": "idx_ai_analysis_runs_status",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_ai_analysis_runs_created_at": {
|
||||
"name": "idx_ai_analysis_runs_created_at",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.attachments": {
|
||||
"name": "attachments",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"message_id": {
|
||||
"name": "message_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"guild_id": {
|
||||
"name": "guild_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"channel_id": {
|
||||
"name": "channel_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"thread_id": {
|
||||
"name": "thread_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"discord_url": {
|
||||
"name": "discord_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"uploaded_url": {
|
||||
"name": "uploaded_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"upload_status": {
|
||||
"name": "upload_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"upload_error": {
|
||||
"name": "upload_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"uploaded_at": {
|
||||
"name": "uploaded_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_attachments_channel": {
|
||||
"name": "idx_attachments_channel",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "channel_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_attachments_message": {
|
||||
"name": "idx_attachments_message",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "message_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_attachments_status": {
|
||||
"name": "idx_attachments_status",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "upload_status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_attachments_channel_created": {
|
||||
"name": "idx_attachments_channel_created",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "channel_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_attachments_thread_created": {
|
||||
"name": "idx_attachments_thread_created",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "thread_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"fk_attachments_message_id": {
|
||||
"name": "fk_attachments_message_id",
|
||||
"tableFrom": "attachments",
|
||||
"tableTo": "messages",
|
||||
"columnsFrom": [
|
||||
"message_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.messages": {
|
||||
"name": "messages",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"guild_id": {
|
||||
"name": "guild_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"channel_id": {
|
||||
"name": "channel_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"thread_id": {
|
||||
"name": "thread_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"edited_content": {
|
||||
"name": "edited_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"edited_at": {
|
||||
"name": "edited_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'text'"
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_status": {
|
||||
"name": "ai_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"ai_moderation_flags": {
|
||||
"name": "ai_moderation_flags",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_moderation_score": {
|
||||
"name": "ai_moderation_score",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_moderation_raw": {
|
||||
"name": "ai_moderation_raw",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_analysis": {
|
||||
"name": "ai_analysis",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_analyzed_at": {
|
||||
"name": "ai_analyzed_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_error": {
|
||||
"name": "ai_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_messages_channel": {
|
||||
"name": "idx_messages_channel",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "channel_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_user": {
|
||||
"name": "idx_messages_user",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_created": {
|
||||
"name": "idx_messages_created",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_thread": {
|
||||
"name": "idx_messages_thread",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "thread_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_channel_created": {
|
||||
"name": "idx_messages_channel_created",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "channel_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_thread_created": {
|
||||
"name": "idx_messages_thread_created",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "thread_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_ai_status_created": {
|
||||
"name": "idx_messages_ai_status_created",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "ai_status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_guild_ai_status_created": {
|
||||
"name": "idx_messages_guild_ai_status_created",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "guild_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "ai_status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.muxer_jobs": {
|
||||
"name": "muxer_jobs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"attempts": {
|
||||
"name": "attempts",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"maxAttempts": {
|
||||
"name": "maxAttempts",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 3
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_muxer_jobs_status": {
|
||||
"name": "idx_muxer_jobs_status",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_muxer_jobs_createdAt": {
|
||||
"name": "idx_muxer_jobs_createdAt",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "createdAt",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ui_state": {
|
||||
"name": "ui_state",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
20
drizzle/migrations/meta/_journal.json
Normal file
20
drizzle/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1778750697764,
|
||||
"tag": "0000_rare_kitty_pryde",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1778764447718,
|
||||
"tag": "0001_curious_zodiak",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Discord Moderation Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
259
frontend/src/App.tsx
Normal file
259
frontend/src/App.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { DashboardLayout } from "./components/layout/DashboardLayout";
|
||||
import { MediaPanel } from "./components/media/MediaPanel";
|
||||
import { MessagesPanel } from "./components/messages/MessagesPanel";
|
||||
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";
|
||||
|
||||
const SAMPLE_RATE = 24000;
|
||||
const CHANNELS = 1;
|
||||
|
||||
export default function App() {
|
||||
const { uiState, setUIState, patchUIState } = useUIState();
|
||||
const voice = useVoiceControl();
|
||||
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>());
|
||||
|
||||
const activeTab = uiState.activeTab || "voice";
|
||||
const selectedVoiceGuild = uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
||||
const selectedVoiceChannel = uiState.selectedVoiceChannel || "";
|
||||
const selectedTextGuild = uiState.selectedTextGuild || uiState.selectedGuild || "";
|
||||
const selectedTextChannel = uiState.selectedTextChannel || "";
|
||||
|
||||
const handleIncomingPcm = useCallback((data: ArrayBuffer) => {
|
||||
const headerView = new DataView(data, 0, 4);
|
||||
const userIdHash = headerView.getInt32(0, true);
|
||||
const audioData = data.slice(4);
|
||||
const int16Array = new Int16Array(audioData);
|
||||
let sum = 0;
|
||||
for (const sample of int16Array) sum += Math.abs(sample / 32768);
|
||||
const average = int16Array.length ? sum / int16Array.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)));
|
||||
|
||||
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 stopStreamingLocal = useCallback(() => {
|
||||
setIsStreaming(false);
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect();
|
||||
processorRef.current = null;
|
||||
}
|
||||
if (audioContextTransmitRef.current) {
|
||||
audioContextTransmitRef.current.close();
|
||||
audioContextTransmitRef.current = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
for (const track of streamRef.current.getTracks()) track.stop();
|
||||
streamRef.current = null;
|
||||
}
|
||||
setLevels(Array.from({ length: 32 }, () => 0.04));
|
||||
}, []);
|
||||
|
||||
const startStreamingLocal = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
setIsStreaming(true);
|
||||
|
||||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||
const audioContext = new AudioContextCtor({ sampleRate: SAMPLE_RATE });
|
||||
audioContextTransmitRef.current = audioContext;
|
||||
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||
processorRef.current = processor;
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
|
||||
processor.onaudioprocess = (event) => {
|
||||
if (!socket.socketRef.current || socket.socketRef.current.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const inputData = event.inputBuffer.getChannelData(0);
|
||||
const pcmData = new Int16Array(inputData.length);
|
||||
for (let i = 0; i < inputData.length; i++) {
|
||||
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
|
||||
}
|
||||
socket.socketRef.current.send(pcmData.buffer);
|
||||
|
||||
// Update local levels from mic
|
||||
let sum = 0;
|
||||
for (let i = 0; i < inputData.length; i++) sum += Math.abs(inputData[i]);
|
||||
const average = inputData.length ? sum / inputData.length : 0;
|
||||
setLevels((prev) => prev.map((_, index) => Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5)));
|
||||
};
|
||||
} catch (err) {
|
||||
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 (
|
||||
<DashboardLayout
|
||||
activeTab={activeTab}
|
||||
wsStatus={socket.status}
|
||||
voiceStatus={voice.voiceStatus}
|
||||
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||
>
|
||||
<div className="md:hidden">
|
||||
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
||||
<div className="mb-4 grid grid-cols-4 gap-2 rounded-2xl bg-muted p-1">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab} className={`rounded-xl px-2 py-2 text-xs font-medium ${activeTab === tab ? "bg-background text-foreground" : "text-muted-foreground"}`} onClick={() => patchUIState({ activeTab: tab })}>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
<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 }),
|
||||
});
|
||||
}
|
||||
96
frontend/src/api/client.ts
Normal file
96
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export type AIStatus = "pending" | "clean" | "warn" | "flagged" | "error";
|
||||
|
||||
export interface MessageRecord {
|
||||
id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
thread_id: string | null;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
content: string;
|
||||
edited_content: string | null;
|
||||
created_at: number;
|
||||
edited_at: number | null;
|
||||
deleted_at: number | null;
|
||||
type: "text" | "edited" | "deleted";
|
||||
metadata: string | null;
|
||||
ai_status?: AIStatus | null;
|
||||
ai_moderation_flags?: string | null;
|
||||
ai_moderation_score?: number | null;
|
||||
ai_moderation_raw?: string | null;
|
||||
ai_analysis?: string | null;
|
||||
ai_analyzed_at?: number | null;
|
||||
ai_error?: string | null;
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
data: T[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export type DashboardMessage = MessageRecord;
|
||||
|
||||
export interface Guild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
code: string;
|
||||
statusCode: number;
|
||||
|
||||
constructor(code: string, message: string, statusCode: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const password = localStorage.getItem("admin-password");
|
||||
const res = await fetch(path, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(password ? { "X-Admin-Password": password } : {}),
|
||||
},
|
||||
...init,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
let code = "REQUEST_FAILED";
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string; message?: string };
|
||||
if (body.message) message = body.message;
|
||||
if (body.error) code = body.error;
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
throw new ApiError(code, message, res.status);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function listMessages(
|
||||
params: URLSearchParams,
|
||||
): Promise<PageResult<MessageRecord>> {
|
||||
return request<PageResult<MessageRecord>>(`/api/messages?${params}`);
|
||||
}
|
||||
|
||||
export async function listReview(
|
||||
params: URLSearchParams,
|
||||
): Promise<PageResult<MessageRecord>> {
|
||||
return request<PageResult<MessageRecord>>(`/api/review?${params}`);
|
||||
}
|
||||
|
||||
export async function reanalyzeMessage(id: string): Promise<void> {
|
||||
await request<void>(`/api/messages/${id}/reanalyze`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function getGuilds(): Promise<Guild[]> {
|
||||
return request<Guild[]>("/api/guilds");
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/messages/MessageCard.tsx
Normal file
51
frontend/src/components/messages/MessageCard.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { RotateCw } from "lucide-react";
|
||||
import type { MessageRecord } from "../../types/messages";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
export interface MessageCardProps {
|
||||
message: MessageRecord;
|
||||
onReanalyze: (id: string) => void;
|
||||
}
|
||||
|
||||
function aiVariant(status: string) {
|
||||
if (status === "clean") return "success";
|
||||
if (status === "warn") return "warning";
|
||||
if (status === "flagged" || status === "error") return "destructive";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
||||
const displayContent = message.edited_content ?? message.content;
|
||||
const aiStatus = message.ai_status ?? "pending";
|
||||
|
||||
return (
|
||||
<article className="rounded-2xl border border-border bg-card p-4 shadow-sm">
|
||||
<div className="flex gap-3">
|
||||
<img
|
||||
src={message.avatar_url ?? "https://cdn.discordapp.com/embed/avatars/0.png"}
|
||||
alt=""
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{message.username || message.user_id}</span>
|
||||
<span className="text-xs text-muted-foreground">{new Date(message.created_at).toLocaleString()}</span>
|
||||
{message.edited_at ? <Badge variant="outline">edited</Badge> : null}
|
||||
{message.deleted_at ? <Badge variant="destructive">deleted</Badge> : null}
|
||||
<Badge variant={aiVariant(aiStatus)}>{aiStatus}</Badge>
|
||||
</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>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/messages/MessageFeed.tsx
Normal file
25
frontend/src/components/messages/MessageFeed.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { MessageRecord } from "../../types/messages";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import { MessageCard } from "./MessageCard";
|
||||
|
||||
export interface MessageFeedProps {
|
||||
messages: MessageRecord[];
|
||||
onReanalyze: (id: string) => void;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
export function MessageFeed({ messages, onReanalyze, emptyText = "No messages found." }: MessageFeedProps) {
|
||||
if (messages.length === 0) {
|
||||
return <div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">{emptyText}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => (
|
||||
<MessageCard key={message.id} message={message} onReanalyze={onReanalyze} />
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/review/ReviewPanel.tsx
Normal file
29
frontend/src/components/review/ReviewPanel.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { MessageRecord } from "../../types/messages";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { MessageFeed } from "../messages/MessageFeed";
|
||||
|
||||
export interface ReviewPanelProps {
|
||||
messages: MessageRecord[];
|
||||
onReanalyze: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
|
||||
const reviewItems = messages.filter(
|
||||
(message) =>
|
||||
message.ai_status === "warn" ||
|
||||
message.ai_status === "flagged" ||
|
||||
message.ai_status === "error",
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Needs Review</CardTitle>
|
||||
<CardDescription>{reviewItems.length} captured messages require attention.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MessageFeed messages={reviewItems} onReanalyze={onReanalyze} emptyText="No warned, flagged, or errored messages." />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
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));
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
41
frontend/src/styles.css
Normal file
41
frontend/src/styles.css
Normal file
@@ -0,0 +1,41 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 222 47% 7%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222 47% 10%;
|
||||
--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;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
32
frontend/src/ws/client.ts
Normal file
32
frontend/src/ws/client.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { MessageRecord } from "../api/client";
|
||||
|
||||
export type DashboardEvent =
|
||||
| { type: "message_created"; data: MessageRecord }
|
||||
| { type: "message_updated"; data: Partial<MessageRecord> & { id: string } }
|
||||
| { type: "message_deleted"; data: { id: string; deleted_at: number } }
|
||||
| { type: "message_analyzed"; data: MessageRecord }
|
||||
| { type: "analysis_queue_status"; data: unknown }
|
||||
| { type: "ui_state"; state: unknown }
|
||||
| { type: "user_state"; users: unknown[] };
|
||||
|
||||
export function connectDashboardSocket(
|
||||
onEvent: (event: DashboardEvent) => void,
|
||||
): WebSocket {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = `${protocol}//${window.location.host}/ws`;
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.addEventListener("message", (evt) => {
|
||||
if (typeof evt.data === "string") {
|
||||
try {
|
||||
const event = JSON.parse(evt.data) as DashboardEvent;
|
||||
onEvent(event);
|
||||
} catch {
|
||||
// ignore malformed JSON
|
||||
}
|
||||
}
|
||||
// Binary frames (PCM audio) are ignored for now
|
||||
});
|
||||
|
||||
return ws;
|
||||
}
|
||||
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
frontend/tsconfig.json
Normal file
16
frontend/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts"]
|
||||
}
|
||||
9
frontend/vite.config.ts
Normal file
9
frontend/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
middlewareMode: false,
|
||||
},
|
||||
});
|
||||
53
package.json
53
package.json
@@ -2,36 +2,58 @@
|
||||
"name": "discord-voice-recorder",
|
||||
"version": "1.0.0",
|
||||
"description": "Discord bot that joins a voice channel and records audio",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"packageManager": "pnpm@10.25.0",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev:server": "tsx watch src/index.ts",
|
||||
"dev:web": "vite --host 0.0.0.0 frontend",
|
||||
"start": "tsx src/index.ts",
|
||||
"build": "tsc --outDir dist",
|
||||
"build": "pnpm run build:web && tsc --outDir dist",
|
||||
"build:web": "vite build frontend --outDir ../public/app --emptyOutDir",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check --diagnostic-level=error .",
|
||||
"format": "biome format --write .",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:migrate:programmatic": "tsx src/database/migrate.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"install:yt-dlp": "sh scripts/install-yt-dlp.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dank074/discord-video-stream": "^6.0.0",
|
||||
"@discordjs/opus": "^0.10.0",
|
||||
"@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",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"discord.js-selfbot-v13": "^3.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"discord.js-selfbot-v13": "workspace:*",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"helmet": "^8.1.0",
|
||||
"libsodium-wrappers": "^0.8.2",
|
||||
"p-retry": "^6.2.0",
|
||||
"pino": "^9.4.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"p-retry": "^8.0.0",
|
||||
"pg": "^8.20.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-http": "^11.0.0",
|
||||
"play-dl": "^1.9.7",
|
||||
"prism-media": "2.0.0-alpha.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"sodium-native": "^4.3.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"sodium-native": "^5.1.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"vite": "^8.0.13",
|
||||
"ws": "^8.20.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -39,11 +61,16 @@
|
||||
"@biomejs/biome": "latest",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"pino-pretty": "^10.3.1",
|
||||
"tsx": "^4.20.6",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.22.0",
|
||||
"vitest": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
6100
pnpm-lock.yaml
generated
6100
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,12 @@
|
||||
packages:
|
||||
- .
|
||||
- vendor/discord.js-selfbot-v13
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@discordjs/opus'
|
||||
- '@lng2004/node-datachannel'
|
||||
- better-sqlite3
|
||||
- esbuild
|
||||
- node-av
|
||||
- sharp
|
||||
- zeromq
|
||||
|
||||
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,417 +0,0 @@
|
||||
:root {
|
||||
--bg: #080a0f;
|
||||
--panel: rgba(18, 22, 32, 0.86);
|
||||
--panel-strong: #121720;
|
||||
--line: rgba(255, 255, 255, 0.12);
|
||||
--text: #edf4ff;
|
||||
--muted: #91a0b6;
|
||||
--faint: #536176;
|
||||
--cyan: #00e5ff;
|
||||
--green: #39ff88;
|
||||
--yellow: #ffe45e;
|
||||
--red: #ff4f6d;
|
||||
--blue: #6275ff;
|
||||
--shadow: 0 24px 70px rgba(0, 0, 0, 0.46);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
font-family: Manrope, sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at 12% 8%, rgba(0, 229, 255, 0.18), transparent 32rem),
|
||||
radial-gradient(circle at 88% 0%, rgba(98, 117, 255, 0.2), transparent 28rem),
|
||||
linear-gradient(145deg, #05060a, var(--bg));
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
mask-image: linear-gradient(to bottom, black, transparent 84%);
|
||||
}
|
||||
|
||||
button, select { font: inherit; }
|
||||
|
||||
.shell {
|
||||
width: min(1440px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 44px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.brand-card,
|
||||
.status-card,
|
||||
.tab-panel,
|
||||
.content-card {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.brand-card {
|
||||
position: relative;
|
||||
padding: 28px;
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
min-height: 190px;
|
||||
}
|
||||
|
||||
.brand-card::after {
|
||||
content: "WATCHER";
|
||||
position: absolute;
|
||||
right: -14px;
|
||||
bottom: -20px;
|
||||
font-family: "Archivo Black", sans-serif;
|
||||
font-size: clamp(58px, 9vw, 132px);
|
||||
letter-spacing: -0.08em;
|
||||
color: rgba(255,255,255,0.035);
|
||||
line-height: 0.78;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
color: var(--cyan);
|
||||
font: 700 12px/1 "JetBrains Mono", monospace;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 20px var(--green);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
max-width: 840px;
|
||||
font-family: "Archivo Black", sans-serif;
|
||||
font-size: clamp(40px, 6vw, 82px);
|
||||
line-height: 0.88;
|
||||
letter-spacing: -0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 18px 0 0;
|
||||
max-width: 720px;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
border-radius: 28px;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.status-row:last-child { border-bottom: 0; }
|
||||
|
||||
.status-label {
|
||||
color: var(--muted);
|
||||
font: 700 12px/1 "JetBrains Mono", monospace;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text);
|
||||
font: 700 13px/1 "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 99px;
|
||||
background: var(--faint);
|
||||
}
|
||||
|
||||
.dot.on { background: var(--green); box-shadow: 0 0 16px var(--green); }
|
||||
.dot.warn { background: var(--yellow); box-shadow: 0 0 16px var(--yellow); }
|
||||
|
||||
.tab-panel {
|
||||
position: sticky;
|
||||
top: 14px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 24px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
border: 1px solid transparent;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
transition: 160ms ease;
|
||||
}
|
||||
|
||||
.tab-btn:hover { color: var(--text); background: rgba(255,255,255,0.06); }
|
||||
.tab-btn.active {
|
||||
color: #061014;
|
||||
background: linear-gradient(135deg, var(--cyan), var(--green));
|
||||
box-shadow: 0 12px 28px rgba(0,229,255,0.18);
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
select {
|
||||
min-width: 240px;
|
||||
color: var(--text);
|
||||
background: rgba(5,8,14,0.78);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 11px 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
.voice-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 380px 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
border-radius: 28px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.card-title h2 {
|
||||
margin: 0;
|
||||
font-family: "Archivo Black", sans-serif;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mini {
|
||||
color: var(--faint);
|
||||
font: 700 11px/1 "JetBrains Mono", monospace;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.field-group { display: grid; gap: 8px; margin-bottom: 14px; }
|
||||
.field-group label { color: var(--muted); font-size: 13px; font-weight: 800; }
|
||||
|
||||
.button-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
padding: 13px 16px;
|
||||
color: #061014;
|
||||
cursor: pointer;
|
||||
font-weight: 900;
|
||||
transition: transform 140ms ease, filter 140ms ease;
|
||||
}
|
||||
|
||||
.btn:hover { transform: translateY(-1px); filter: brightness(1.08); }
|
||||
.btn-primary { background: linear-gradient(135deg, var(--cyan), var(--blue)); color: white; }
|
||||
.btn-success { background: linear-gradient(135deg, var(--green), var(--cyan)); }
|
||||
.btn-danger { background: linear-gradient(135deg, var(--red), #ff9a6b); color: white; }
|
||||
|
||||
.voice-status { color: var(--muted); font-size: 13px; margin-top: 12px; min-height: 20px; }
|
||||
|
||||
.visualizer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 5px;
|
||||
height: 130px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: rgba(0,0,0,0.22);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
min-width: 5px;
|
||||
border-radius: 999px;
|
||||
height: 3px;
|
||||
background: linear-gradient(to top, var(--blue), var(--cyan), var(--green));
|
||||
box-shadow: 0 0 18px rgba(0,229,255,0.16);
|
||||
}
|
||||
|
||||
.participants {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255,255,255,0.035);
|
||||
}
|
||||
|
||||
.user-item.speaking { border-color: rgba(57,255,136,0.55); background: rgba(57,255,136,0.08); }
|
||||
.user-item img { width: 34px; height: 34px; border-radius: 999px; }
|
||||
|
||||
.feed {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: rgba(255,255,255,0.035);
|
||||
}
|
||||
|
||||
.event-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, var(--blue), var(--cyan));
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.avatar img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
|
||||
|
||||
.name { font-weight: 900; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.time { color: var(--faint); font: 600 11px/1 "JetBrains Mono", monospace; white-space: nowrap; }
|
||||
.message-text { color: #dbe6f7; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
.sticker-strip, .attachment-strip { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.sticker-img { width: 96px; height: 96px; object-fit: contain; border-radius: 16px; background: rgba(0,0,0,0.22); border: 1px solid var(--line); padding: 8px; }
|
||||
.attachment-chip { color: var(--cyan); text-decoration: none; border: 1px solid var(--line); border-radius: 14px; padding: 8px 10px; font: 700 12px/1 "JetBrains Mono", monospace; background: rgba(0,229,255,0.06); }
|
||||
.embed-card { border-left: 4px solid var(--blue); border-radius: 16px; padding: 12px; background: rgba(98,117,255,0.08); display: grid; gap: 8px; }
|
||||
.embed-title { font-weight: 900; color: var(--text); }
|
||||
.embed-description { color: var(--muted); line-height: 1.5; white-space: pre-wrap; }
|
||||
.embed-image { max-width: 360px; width: 100%; border-radius: 14px; border: 1px solid var(--line); }
|
||||
|
||||
.badges { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.badge {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 5px 9px;
|
||||
color: var(--muted);
|
||||
font: 700 10px/1 "JetBrains Mono", monospace;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.badge.edit { color: var(--yellow); border-color: rgba(255,228,94,0.36); }
|
||||
.badge.delete { color: var(--red); border-color: rgba(255,79,109,0.42); }
|
||||
|
||||
.filename { font-size: 13px; font-weight: 900; word-break: break-word; }
|
||||
.link { color: var(--cyan); text-decoration: none; font-weight: 900; }
|
||||
.link:hover { text-decoration: underline; }
|
||||
|
||||
.empty {
|
||||
padding: 34px;
|
||||
text-align: center;
|
||||
color: var(--faint);
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: none;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 14px;
|
||||
color: #ffd8df;
|
||||
border: 1px solid rgba(255,79,109,0.5);
|
||||
border-radius: 16px;
|
||||
background: rgba(255,79,109,0.12);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.hero, .voice-layout { grid-template-columns: 1fr; }
|
||||
.tab-panel { align-items: stretch; flex-direction: column; }
|
||||
.filter-row { align-items: stretch; flex-direction: column; }
|
||||
select { width: 100%; min-width: 0; }
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Discord Moderation Watcher</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/dashboard.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<div class="brand-card">
|
||||
<div class="eyebrow"><span class="pulse"></span> Discord moderation watcher</div>
|
||||
<h1>Voice. Text. One Watch Floor.</h1>
|
||||
<p class="subtitle">Static shared-state dashboard with legacy working voice bridge and captured Discord messages.</p>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-row"><span class="status-label">WebSocket</span><span class="status-value"><span id="wsDot" class="dot"></span><span id="wsStatusText">Connecting</span></span></div>
|
||||
<div class="status-row"><span class="status-label">Voice Link</span><span id="voiceStatusText" class="status-value">Not connected</span></div>
|
||||
<div class="status-row"><span class="status-label">Active Tab</span><span id="activeTabLabel" class="status-value">Voice</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="tab-panel">
|
||||
<div class="tabs"><button class="tab-btn active" data-tab="voice">Voice</button><button class="tab-btn" data-tab="text">Text</button></div>
|
||||
<div class="filter-row"><span>Channel / Thread</span><select id="channelFilter"><option value="">Select channel</option></select></div>
|
||||
</nav>
|
||||
|
||||
<div id="errorBox" class="error"></div>
|
||||
|
||||
<section id="voice" class="tab-content active">
|
||||
<div class="voice-layout">
|
||||
<div class="content-card">
|
||||
<div class="card-title"><h2>Voice Control</h2><span class="mini">bridge</span></div>
|
||||
<div class="field-group"><label for="guildSelect">Guild</label><select id="guildSelect"><option value="">Select guild</option></select></div>
|
||||
<div class="field-group"><label for="channelSelect">Voice Channel</label><select id="channelSelect"><option value="">Select voice channel</option></select></div>
|
||||
<div class="button-row"><button id="joinVoiceBtn" class="btn btn-success">Join</button><button id="disconnectVoiceBtn" class="btn btn-danger">Disconnect</button></div>
|
||||
<div class="voice-status" id="voiceStatusNote">Idle</div>
|
||||
</div>
|
||||
<div class="content-card">
|
||||
<div class="card-title"><h2>Live Audio</h2><span class="mini" id="listenStatus">Speaker Off</span></div>
|
||||
<div style="display:grid;gap:12px;grid-template-columns:1fr 1fr;margin-bottom:14px"><button id="toggleBtn" class="btn btn-primary">Start Transmitting</button><button id="listenBtn" class="btn btn-success">Join Listen Channel</button></div>
|
||||
<div class="visualizer" id="visualizer"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-card" style="margin-top:18px"><div class="card-title"><h2>Participants</h2><span class="mini">speaking now</span></div><div id="userList" class="participants"></div></div>
|
||||
</section>
|
||||
|
||||
<section id="text" class="tab-content"><div class="content-card"><div class="card-title"><h2>Text Watch</h2><span class="mini">create / edit / delete</span></div><div id="textList" class="feed"><div class="empty">Select channel to view text captures</div></div></div></section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const state = {
|
||||
socket: null,
|
||||
selectedGuild: '',
|
||||
selectedVoiceChannel: '',
|
||||
selectedTextChannel: '',
|
||||
activeTab: 'voice',
|
||||
text: [],
|
||||
isStreaming: false,
|
||||
isListening: false,
|
||||
localStreaming: false,
|
||||
localListening: false,
|
||||
audioContextTransmit: null,
|
||||
audioContextListen: null,
|
||||
processor: null,
|
||||
userTimelines: new Map(),
|
||||
applyingServerState: false,
|
||||
};
|
||||
const SAMPLE_RATE = 24000;
|
||||
const CHANNELS = 1;
|
||||
const el = {
|
||||
wsDot: document.getElementById('wsDot'), wsStatusText: document.getElementById('wsStatusText'), activeTabLabel: document.getElementById('activeTabLabel'), errorBox: document.getElementById('errorBox'), guildSelect: document.getElementById('guildSelect'), channelSelect: document.getElementById('channelSelect'), channelFilter: document.getElementById('channelFilter'), joinVoiceBtn: document.getElementById('joinVoiceBtn'), disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'), voiceStatusText: document.getElementById('voiceStatusText'), voiceStatusNote: document.getElementById('voiceStatusNote'), toggleBtn: document.getElementById('toggleBtn'), listenBtn: document.getElementById('listenBtn'), listenStatus: document.getElementById('listenStatus'), visualizer: document.getElementById('visualizer'), userList: document.getElementById('userList'), textList: document.getElementById('textList')
|
||||
};
|
||||
for (let i = 0; i < 32; i++) { const bar = document.createElement('div'); bar.className = 'bar'; el.visualizer.appendChild(bar); }
|
||||
const bars = [...document.querySelectorAll('.bar')];
|
||||
|
||||
async function apiRequest(url, options = {}) { const response = await fetch(url, { headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options }); if (!response.ok) { const error = await response.json().catch(() => ({ message: response.statusText })); throw new Error(error.message || response.statusText); } return response.json(); }
|
||||
function showError(message) { el.errorBox.textContent = message; el.errorBox.style.display = 'block'; setTimeout(() => { el.errorBox.style.display = 'none'; }, 4500); }
|
||||
function postUIState(patch) { return apiRequest('/api/ui-state', { method: 'POST', body: JSON.stringify(patch) }); }
|
||||
function renderOptions(select, items, placeholder) { select.replaceChildren(); const first = document.createElement('option'); first.value = ''; first.textContent = placeholder; select.appendChild(first); for (const item of items) { const option = document.createElement('option'); option.value = item.id; option.textContent = item.name; select.appendChild(option); } }
|
||||
function appendOptions(select, items) { const existing = new Set([...select.options].map((option) => option.value)); for (const item of items) { if (existing.has(item.id)) continue; const option = document.createElement('option'); option.value = item.id; option.textContent = item.name; select.appendChild(option); } }
|
||||
function appendEmpty(parent, message) { const empty = document.createElement('div'); empty.className = 'empty'; empty.textContent = message; parent.appendChild(empty); }
|
||||
function appendBadge(parent, label, className) { const badge = document.createElement('span'); badge.className = `badge ${className}`; badge.textContent = label; parent.appendChild(badge); }
|
||||
function parseMetadata(value) { if (!value) return {}; try { return JSON.parse(value); } catch { return {}; } }
|
||||
|
||||
async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.guildSelect, guilds, 'Select guild'); if (state.selectedGuild) { el.guildSelect.value = state.selectedGuild; await loadChannels(state.selectedGuild); } else if (guilds[0]?.id) { await postUIState({ selectedGuild: guilds[0].id }); } }
|
||||
async function loadChannels(guildId) { if (!guildId) return; const [voiceChannels, watchChannels] = await Promise.all([apiRequest(`/api/guilds/${guildId}/voice-channels`), apiRequest(`/api/guilds/${guildId}/channels`)]); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); renderOptions(el.channelFilter, watchChannels, 'Select channel'); if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; apiRequest(`/api/guilds/${guildId}/threads`).then((threads) => { appendOptions(el.channelFilter, threads); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; }).catch((error) => showError(`Thread discovery failed: ${error.message}`)); }
|
||||
async function refreshStatus() { try { const status = await apiRequest('/api/status'); el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected'; el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle'; } catch (error) { showError(error.message); } }
|
||||
async function connectVoice() { const guildId = el.guildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedGuild: guildId, selectedVoiceChannel: channelId }); const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) }); el.voiceStatusText.textContent = status.activeChannelName || 'Connected'; el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`; }
|
||||
async function disconnectVoice() { await apiRequest('/api/disconnect', { method: 'POST' }); await refreshStatus(); }
|
||||
|
||||
function connectWebSocket() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; state.socket = new WebSocket(`${protocol}//${location.host}/ws`); state.socket.binaryType = 'arraybuffer'; state.socket.onopen = () => { el.wsDot.classList.add('on'); el.wsStatusText.textContent = 'Connected'; }; state.socket.onclose = () => { el.wsDot.classList.remove('on'); el.wsStatusText.textContent = 'Reconnecting'; setTimeout(connectWebSocket, 2500); }; state.socket.onerror = () => { el.wsDot.classList.remove('on'); el.wsDot.classList.add('warn'); el.wsStatusText.textContent = 'Socket error'; }; state.socket.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { handleIncomingPCM(event.data); return; } try { handleJsonEvent(event.data); } catch {} }; }
|
||||
function handleJsonEvent(raw) { const message = JSON.parse(raw); if (message.type === 'ui_state') return applyServerState(message.state); if (message.type === 'user_state') return renderUsers(message.users || []); if (message.type === 'message_created') { state.text.unshift(message.data); renderText(); } if (message.type === 'message_updated') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, { edited_content: message.data.edited_content, edited_at: message.data.edited_at, type: 'edited' }); renderText(); } if (message.type === 'message_deleted') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, { deleted_at: message.data.deleted_at, type: 'deleted' }); renderText(); } if (message.type === 'attachment_uploaded') fetchText(); if (message.type === 'message_analyzed') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, message.data); renderText(); } }
|
||||
|
||||
async function applyServerState(next) {
|
||||
if (!next || state.applyingServerState) return;
|
||||
state.applyingServerState = true;
|
||||
const guildChanged = next.selectedGuild !== state.selectedGuild;
|
||||
const textChanged = next.selectedTextChannel !== state.selectedTextChannel;
|
||||
state.selectedGuild = next.selectedGuild || '';
|
||||
state.selectedVoiceChannel = next.selectedVoiceChannel || '';
|
||||
state.selectedTextChannel = next.selectedTextChannel || '';
|
||||
state.activeTab = next.activeTab || 'voice';
|
||||
state.isListening = !!next.isListening;
|
||||
state.isStreaming = !!next.isStreaming;
|
||||
el.guildSelect.value = state.selectedGuild;
|
||||
if (guildChanged && state.selectedGuild) await loadChannels(state.selectedGuild);
|
||||
el.channelSelect.value = state.selectedVoiceChannel;
|
||||
el.channelFilter.value = state.selectedTextChannel;
|
||||
applyActiveTab(state.activeTab);
|
||||
if (textChanged || state.activeTab === 'text') await fetchText().catch((error) => showError(error.message));
|
||||
await reconcileListenState();
|
||||
await reconcileStreamingState();
|
||||
state.applyingServerState = false;
|
||||
}
|
||||
|
||||
function applyActiveTab(tab) { document.querySelectorAll('.tab-btn').forEach((item) => item.classList.toggle('active', item.dataset.tab === tab)); document.querySelectorAll('.tab-content').forEach((item) => item.classList.toggle('active', item.id === tab)); el.activeTabLabel.textContent = tab === 'text' ? 'Text' : 'Voice'; }
|
||||
async function reconcileListenState() { if (state.isListening && !state.localListening) { try { await startListeningLocal(); } catch (error) { showError(`Speaker error: ${error.message}`); await postUIState({ isListening: false }); } } else if (!state.isListening && state.localListening) { stopListeningLocal(); } }
|
||||
async function reconcileStreamingState() { if (state.isStreaming && !state.localStreaming) { try { await startStreamingLocal(); } catch (error) { showError(`Microphone error: ${error.message}`); await postUIState({ isStreaming: false }); } } else if (!state.isStreaming && state.localStreaming) { stopStreamingLocal(); } }
|
||||
|
||||
function renderUsers(users) { el.userList.replaceChildren(); if (users.length === 0) return appendEmpty(el.userList, 'No active speakers'); for (const user of users) { const row = document.createElement('div'); row.className = `user-item${user.speaking ? ' speaking' : ''}`; const img = document.createElement('img'); img.src = user.avatar || ''; img.alt = ''; const name = document.createElement('span'); name.textContent = user.username; row.append(img, name); el.userList.appendChild(row); } }
|
||||
async function fetchText() { if (!state.selectedTextChannel) return renderText(); const result = await apiRequest(`/api/messages?channel=${encodeURIComponent(state.selectedTextChannel)}&type=text&limit=80`); state.text = result.data || []; renderText(); }
|
||||
function renderText() { el.textList.replaceChildren(); if (!state.selectedTextChannel) return appendEmpty(el.textList, 'Select channel to view text captures'); if (state.text.length === 0) return appendEmpty(el.textList, 'No text captures yet'); for (const msg of state.text) { const metadata = parseMetadata(msg.metadata); const card = document.createElement('article'); card.className = 'event-card'; const head = document.createElement('div'); head.className = 'event-head'; const author = document.createElement('div'); author.className = 'author'; const avatar = document.createElement('div'); avatar.className = 'avatar'; if (msg.avatar_url) { const img = document.createElement('img'); img.src = msg.avatar_url; img.alt = ''; avatar.appendChild(img); } const name = document.createElement('div'); name.className = 'name'; name.textContent = msg.username || msg.user_id; author.append(avatar, name); const time = document.createElement('div'); time.className = 'time'; time.textContent = new Date(msg.created_at).toLocaleString(); head.append(author, time); const text = document.createElement('div'); text.className = 'message-text'; text.textContent = msg.edited_content || msg.content || '(empty message)'; card.append(head, text); 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); } }
|
||||
function appendAIAnalysis(card, msg) { const status = msg.ai_status || 'pending'; const wrap = document.createElement('div'); wrap.className = 'badges'; const badge = document.createElement('span'); badge.className = `badge ${status === 'flagged' ? 'delete' : status === 'clean' ? 'edit' : ''}`; badge.textContent = `AI: ${status}`; wrap.appendChild(badge); if (msg.ai_moderation_flags) { const flags = document.createElement('span'); flags.className = 'badge delete'; try { flags.textContent = JSON.parse(msg.ai_moderation_flags).join(', '); } catch { flags.textContent = msg.ai_moderation_flags; } wrap.appendChild(flags); } card.appendChild(wrap); if (msg.ai_analysis) { const analysis = document.createElement('div'); analysis.className = 'embed-description'; analysis.textContent = msg.ai_analysis; card.appendChild(analysis); } if (msg.ai_error) { const error = document.createElement('div'); error.className = 'embed-description'; error.textContent = `AI error: ${msg.ai_error}`; card.appendChild(error); } }
|
||||
function appendMedia(card, metadata) { const stickers = document.createElement('div'); stickers.className = 'sticker-strip'; for (const sticker of metadata.stickers || []) { const img = document.createElement('img'); img.className = 'sticker-img'; img.src = sticker.url; img.alt = sticker.name; stickers.appendChild(img); } if (stickers.childElementCount) card.appendChild(stickers); const embeds = document.createElement('div'); embeds.className = 'feed'; for (const embed of metadata.embeds || []) { const item = document.createElement('div'); item.className = 'embed-card'; if (embed.title) { const title = document.createElement(embed.url ? 'a' : 'div'); title.className = 'embed-title'; title.textContent = embed.title; if (embed.url) { title.href = embed.url; title.target = '_blank'; title.rel = 'noreferrer'; } item.appendChild(title); } if (embed.description) { const desc = document.createElement('div'); desc.className = 'embed-description'; desc.textContent = embed.description; item.appendChild(desc); } if (embed.image || embed.thumbnail) { const img = document.createElement('img'); img.className = 'embed-image'; img.src = embed.image || embed.thumbnail; img.alt = embed.title || 'embed image'; item.appendChild(img); } embeds.appendChild(item); } if (embeds.childElementCount) card.appendChild(embeds); const attachments = document.createElement('div'); attachments.className = 'attachment-strip'; for (const attachment of metadata.attachments || []) { const link = document.createElement('a'); link.className = 'attachment-chip'; link.href = attachment.url; link.target = '_blank'; link.rel = 'noreferrer'; link.textContent = `${attachment.name} (${(attachment.size / 1024).toFixed(1)}KB)`; attachments.appendChild(link); } if (attachments.childElementCount) card.appendChild(attachments); }
|
||||
|
||||
function handleIncomingPCM(data) { if (!state.localListening || !state.audioContextListen) return; const headerView = new DataView(data, 0, 4); const userIdHash = headerView.getInt32(0, true); const audioData = data.slice(4); const int16Array = new Int16Array(audioData); const float32Array = new Float32Array(int16Array.length); for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768; const audioBuffer = state.audioContextListen.createBuffer(CHANNELS, float32Array.length / CHANNELS, SAMPLE_RATE); const nowBuffering = audioBuffer.getChannelData(0); for (let i = 0; i < audioBuffer.length; i++) nowBuffering[i] = float32Array[i]; const source = state.audioContextListen.createBufferSource(); source.buffer = audioBuffer; source.connect(state.audioContextListen.destination); const currentTime = state.audioContextListen.currentTime; let userNextStartTime = state.userTimelines.get(userIdHash) || 0; if (userNextStartTime < currentTime) userNextStartTime = currentTime + 0.05; source.start(userNextStartTime); userNextStartTime += audioBuffer.duration; state.userTimelines.set(userIdHash, userNextStartTime); }
|
||||
async function startStreamingLocal() { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); state.localStreaming = true; el.toggleBtn.textContent = 'Stop Transmitting'; state.audioContextTransmit = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE }); const source = state.audioContextTransmit.createMediaStreamSource(stream); const analyser = state.audioContextTransmit.createAnalyser(); analyser.fftSize = 64; source.connect(analyser); const dataArray = new Uint8Array(analyser.frequencyBinCount); state.processor = state.audioContextTransmit.createScriptProcessor(4096, 1, 1); source.connect(state.processor); state.processor.connect(state.audioContextTransmit.destination); state.processor.onaudioprocess = (event) => { if (!state.localStreaming || state.socket.readyState !== WebSocket.OPEN) return; const inputData = event.inputBuffer.getChannelData(0); const pcmData = new Int16Array(inputData.length); for (let i = 0; i < inputData.length; i++) pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767; state.socket.send(pcmData.buffer); analyser.getByteFrequencyData(dataArray); bars.forEach((bar, index) => { const percent = (dataArray[index] / 255) * 100; bar.style.height = `${Math.max(2, percent)}%`; }); }; }
|
||||
function stopStreamingLocal() { state.localStreaming = false; if (state.processor) state.processor.disconnect(); if (state.audioContextTransmit) state.audioContextTransmit.close(); state.processor = null; state.audioContextTransmit = null; el.toggleBtn.textContent = 'Start Transmitting'; bars.forEach((bar) => { bar.style.height = '2px'; }); }
|
||||
async function startListeningLocal() { if (!state.audioContextListen) state.audioContextListen = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE }); await state.audioContextListen.resume(); state.localListening = true; el.listenBtn.textContent = 'Stop Listening'; el.listenStatus.textContent = 'Listening Live...'; }
|
||||
function stopListeningLocal() { state.audioContextListen?.suspend(); state.userTimelines.clear(); state.localListening = false; el.listenBtn.textContent = 'Join Listen Channel'; el.listenStatus.textContent = 'Speaker Off'; }
|
||||
function updateVisualizer(level) { bars.forEach((bar, index) => { const wave = Math.sin(index * 0.55 + Date.now() / 140) * 0.35 + 0.65; bar.style.height = `${Math.max(3, level * 190 * wave)}px`; }); }
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach((button) => { button.addEventListener('click', () => postUIState({ activeTab: button.dataset.tab }).catch((error) => showError(error.message))); });
|
||||
el.guildSelect.addEventListener('change', () => postUIState({ selectedGuild: el.guildSelect.value, selectedVoiceChannel: '', selectedTextChannel: '' }).catch((error) => showError(error.message)));
|
||||
el.channelSelect.addEventListener('change', () => postUIState({ selectedVoiceChannel: el.channelSelect.value }).catch((error) => showError(error.message)));
|
||||
el.joinVoiceBtn.addEventListener('click', () => connectVoice().catch((error) => showError(error.message)));
|
||||
el.disconnectVoiceBtn.addEventListener('click', () => disconnectVoice().catch((error) => showError(error.message)));
|
||||
el.toggleBtn.addEventListener('click', () => postUIState({ isStreaming: !state.isStreaming }).catch((error) => showError(error.message)));
|
||||
el.listenBtn.addEventListener('click', () => postUIState({ isListening: !state.isListening }).catch((error) => showError(error.message)));
|
||||
el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); });
|
||||
|
||||
connectWebSocket();
|
||||
loadGuilds().then(refreshStatus).then(() => apiRequest('/api/ui-state')).then(applyServerState).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
|
||||
241
scripts/migrate-data.ts
Normal file
241
scripts/migrate-data.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { createChildLogger } from "../src/logger";
|
||||
import * as postgres from "../src/database/postgres";
|
||||
|
||||
const logger = createChildLogger("migrate-data");
|
||||
|
||||
interface MuxerJob {
|
||||
id: string;
|
||||
data: string;
|
||||
status: string;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
thread_id?: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url?: string;
|
||||
content: string;
|
||||
edited_content?: string;
|
||||
created_at: number;
|
||||
edited_at?: number;
|
||||
deleted_at?: number;
|
||||
type: string;
|
||||
metadata?: string;
|
||||
ai_status: string;
|
||||
ai_moderation_flags?: string;
|
||||
ai_moderation_score?: number;
|
||||
ai_moderation_raw?: string;
|
||||
ai_analysis?: string;
|
||||
ai_analyzed_at?: number;
|
||||
ai_error?: string;
|
||||
}
|
||||
|
||||
interface Attachment {
|
||||
id: string;
|
||||
message_id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
thread_id?: string;
|
||||
user_id: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
type: string;
|
||||
discord_url: string;
|
||||
uploaded_url?: string;
|
||||
upload_status: string;
|
||||
upload_error?: string;
|
||||
created_at: number;
|
||||
uploaded_at?: number;
|
||||
}
|
||||
|
||||
interface UiState {
|
||||
key: string;
|
||||
value: string;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
async function migrateData(): Promise<void> {
|
||||
let sqliteDb: Database.Database | null = null;
|
||||
|
||||
try {
|
||||
logger.info("Starting data migration from SQLite to PostgreSQL");
|
||||
|
||||
// Open SQLite database
|
||||
const dbPath = path.join(process.cwd(), ".muxer-queue.db");
|
||||
sqliteDb = new Database(dbPath);
|
||||
logger.info({ dbPath }, "SQLite database opened");
|
||||
|
||||
// Initialize PostgreSQL pool
|
||||
const pool = postgres.getPool();
|
||||
logger.info("PostgreSQL connection pool initialized");
|
||||
|
||||
// Migrate muxer_jobs table
|
||||
logger.info("Migrating muxer_jobs table...");
|
||||
const muxerJobsStmt = sqliteDb.prepare("SELECT * FROM muxer_jobs");
|
||||
const muxerJobs = muxerJobsStmt.all() as MuxerJob[];
|
||||
|
||||
for (const job of muxerJobs) {
|
||||
await postgres.query(
|
||||
`INSERT INTO muxer_jobs (id, data, status, attempts, maxAttempts, createdAt, updatedAt, error)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[
|
||||
job.id,
|
||||
job.data,
|
||||
job.status,
|
||||
job.attempts,
|
||||
job.maxAttempts,
|
||||
job.createdAt,
|
||||
job.updatedAt,
|
||||
job.error || null,
|
||||
],
|
||||
);
|
||||
}
|
||||
logger.info({ count: muxerJobs.length }, "Migrated muxer_jobs");
|
||||
|
||||
// Migrate messages table
|
||||
logger.info("Migrating messages table...");
|
||||
const messagesStmt = sqliteDb.prepare("SELECT * FROM messages");
|
||||
const messages = messagesStmt.all() as Message[];
|
||||
|
||||
for (const msg of messages) {
|
||||
await postgres.query(
|
||||
`INSERT INTO messages (
|
||||
id, guild_id, channel_id, thread_id, user_id, username, avatar_url,
|
||||
content, edited_content, created_at, edited_at, deleted_at, type,
|
||||
metadata, ai_status, ai_moderation_flags, ai_moderation_score,
|
||||
ai_moderation_raw, ai_analysis, ai_analyzed_at, ai_error
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
|
||||
$16, $17, $18, $19, $20, $21
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[
|
||||
msg.id,
|
||||
msg.guild_id,
|
||||
msg.channel_id,
|
||||
msg.thread_id || null,
|
||||
msg.user_id,
|
||||
msg.username,
|
||||
msg.avatar_url || null,
|
||||
msg.content,
|
||||
msg.edited_content || null,
|
||||
msg.created_at,
|
||||
msg.edited_at || null,
|
||||
msg.deleted_at || null,
|
||||
msg.type,
|
||||
msg.metadata || null,
|
||||
msg.ai_status,
|
||||
msg.ai_moderation_flags || null,
|
||||
msg.ai_moderation_score || null,
|
||||
msg.ai_moderation_raw || null,
|
||||
msg.ai_analysis || null,
|
||||
msg.ai_analyzed_at || null,
|
||||
msg.ai_error || null,
|
||||
],
|
||||
);
|
||||
}
|
||||
logger.info({ count: messages.length }, "Migrated messages");
|
||||
|
||||
// Migrate attachments table
|
||||
logger.info("Migrating attachments table...");
|
||||
const attachmentsStmt = sqliteDb.prepare("SELECT * FROM attachments");
|
||||
const attachments = attachmentsStmt.all() as Attachment[];
|
||||
|
||||
for (const att of attachments) {
|
||||
await postgres.query(
|
||||
`INSERT INTO attachments (
|
||||
id, message_id, guild_id, channel_id, thread_id, user_id, filename,
|
||||
size, type, discord_url, uploaded_url, upload_status, upload_error,
|
||||
created_at, uploaded_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[
|
||||
att.id,
|
||||
att.message_id,
|
||||
att.guild_id,
|
||||
att.channel_id,
|
||||
att.thread_id || null,
|
||||
att.user_id,
|
||||
att.filename,
|
||||
att.size,
|
||||
att.type,
|
||||
att.discord_url,
|
||||
att.uploaded_url || null,
|
||||
att.upload_status,
|
||||
att.upload_error || null,
|
||||
att.created_at,
|
||||
att.uploaded_at || null,
|
||||
],
|
||||
);
|
||||
}
|
||||
logger.info({ count: attachments.length }, "Migrated attachments");
|
||||
|
||||
// Migrate ui_state table
|
||||
logger.info("Migrating ui_state table...");
|
||||
const uiStateStmt = sqliteDb.prepare("SELECT * FROM ui_state");
|
||||
const uiStates = uiStateStmt.all() as UiState[];
|
||||
|
||||
for (const state of uiStates) {
|
||||
await postgres.query(
|
||||
`INSERT INTO ui_state (key, value, updated_at)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||
[state.key, state.value, state.updated_at],
|
||||
);
|
||||
}
|
||||
logger.info({ count: uiStates.length }, "Migrated ui_state");
|
||||
|
||||
logger.info(
|
||||
{
|
||||
muxerJobs: muxerJobs.length,
|
||||
messages: messages.length,
|
||||
attachments: attachments.length,
|
||||
uiState: uiStates.length,
|
||||
},
|
||||
"Data migration completed successfully",
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
"Data migration failed",
|
||||
);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
// Close SQLite connection
|
||||
if (sqliteDb) {
|
||||
sqliteDb.close();
|
||||
logger.info("SQLite database closed");
|
||||
}
|
||||
|
||||
// Close PostgreSQL pool
|
||||
await postgres.closePool();
|
||||
logger.info("PostgreSQL connection pool closed");
|
||||
}
|
||||
}
|
||||
|
||||
// Run migration
|
||||
migrateData().catch((error) => {
|
||||
logger.error(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Unhandled error in migration",
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
97
setup-postgres.sh
Executable file
97
setup-postgres.sh
Executable file
@@ -0,0 +1,97 @@
|
||||
#!/bin/bash
|
||||
# PostgreSQL Migration Quick Start Script
|
||||
# Run this to set up PostgreSQL migration
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Discord Bot - PostgreSQL Migration Setup"
|
||||
echo "==========================================="
|
||||
echo ""
|
||||
|
||||
# Check if PostgreSQL is installed
|
||||
if ! command -v psql &> /dev/null; then
|
||||
echo "❌ PostgreSQL is not installed. Please install PostgreSQL first."
|
||||
echo " macOS: brew install postgresql"
|
||||
echo " Ubuntu: sudo apt-get install postgresql postgresql-contrib"
|
||||
echo " Windows: Download from https://www.postgresql.org/download/windows/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ PostgreSQL found"
|
||||
echo ""
|
||||
|
||||
# Check if Node.js is installed
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "❌ Node.js is not installed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Node.js found"
|
||||
echo ""
|
||||
|
||||
# Create .env if it doesn't exist
|
||||
if [ ! -f .env ]; then
|
||||
echo "📝 Creating .env file..."
|
||||
cp .env.example .env
|
||||
echo "⚠️ Please update .env with your PostgreSQL connection string"
|
||||
echo " DATABASE_URL=postgresql://user:password@localhost:5432/discord_bot"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Install dependencies
|
||||
echo "📦 Installing dependencies..."
|
||||
pnpm add pg pg-pool node-pg-migrate
|
||||
pnpm add -D @types/pg @types/node-pg-migrate
|
||||
pnpm remove better-sqlite3 @types/better-sqlite3 2>/dev/null || true
|
||||
|
||||
echo "✅ Dependencies installed"
|
||||
echo ""
|
||||
|
||||
# Create database
|
||||
read -p "Enter PostgreSQL username (default: postgres): " PG_USER
|
||||
PG_USER=${PG_USER:-postgres}
|
||||
|
||||
read -p "Enter PostgreSQL password: " -s PG_PASSWORD
|
||||
echo ""
|
||||
|
||||
read -p "Enter database name (default: discord_bot): " DB_NAME
|
||||
DB_NAME=${DB_NAME:-discord_bot}
|
||||
|
||||
read -p "Enter PostgreSQL host (default: localhost): " PG_HOST
|
||||
PG_HOST=${PG_HOST:-localhost}
|
||||
|
||||
read -p "Enter PostgreSQL port (default: 5432): " PG_PORT
|
||||
PG_PORT=${PG_PORT:-5432}
|
||||
|
||||
echo ""
|
||||
echo "🗄️ Creating database..."
|
||||
|
||||
PGPASSWORD=$PG_PASSWORD psql -h $PG_HOST -U $PG_USER -p $PG_PORT -tc "SELECT 1 FROM pg_database WHERE datname = '$DB_NAME'" | grep -q 1 || \
|
||||
PGPASSWORD=$PG_PASSWORD psql -h $PG_HOST -U $PG_USER -p $PG_PORT -c "CREATE DATABASE $DB_NAME"
|
||||
|
||||
echo "✅ Database created"
|
||||
echo ""
|
||||
|
||||
# Update .env with connection string
|
||||
DATABASE_URL="postgresql://$PG_USER:$PG_PASSWORD@$PG_HOST:$PG_PORT/$DB_NAME"
|
||||
sed -i.bak "s|DATABASE_URL=.*|DATABASE_URL=$DATABASE_URL|" .env
|
||||
rm -f .env.bak
|
||||
|
||||
echo "✅ .env updated with DATABASE_URL"
|
||||
echo ""
|
||||
|
||||
# Run migrations
|
||||
echo "🔄 Running migrations..."
|
||||
npx node-pg-migrate up
|
||||
|
||||
echo "✅ Migrations completed"
|
||||
echo ""
|
||||
|
||||
echo "🎉 PostgreSQL migration setup complete!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Review the migration files in src/db/migrations/"
|
||||
echo "2. Update imports in your code (see POSTGRES_IMPLEMENTATION.md)"
|
||||
echo "3. Run: pnpm run typecheck"
|
||||
echo "4. Run: pnpm run dev"
|
||||
echo ""
|
||||
61
src/audio/ffmpegProcess.ts
Normal file
61
src/audio/ffmpegProcess.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { spawn } from "child_process";
|
||||
|
||||
export interface MuxFfmpegArgsOptions {
|
||||
inputs: string[];
|
||||
filter: string;
|
||||
output: string;
|
||||
codec: string;
|
||||
audioFrequency?: number;
|
||||
audioChannels?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds ffmpeg argument array for muxing audio clips.
|
||||
*/
|
||||
export function buildMuxFfmpegArgs(options: MuxFfmpegArgsOptions): string[] {
|
||||
const args: string[] = ["-y"];
|
||||
|
||||
for (const input of options.inputs) {
|
||||
args.push("-i", input);
|
||||
}
|
||||
|
||||
args.push("-filter_complex", options.filter);
|
||||
args.push("-map", "[out]");
|
||||
args.push("-codec:a", options.codec);
|
||||
|
||||
if (options.audioFrequency !== undefined) {
|
||||
args.push("-ar", String(options.audioFrequency));
|
||||
}
|
||||
|
||||
if (options.audioChannels !== undefined) {
|
||||
args.push("-ac", String(options.audioChannels));
|
||||
}
|
||||
|
||||
args.push(options.output);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs ffmpeg with the given arguments.
|
||||
* Resolves on successful (code 0) exit, rejects on error or non-zero exit.
|
||||
*/
|
||||
export function runFfmpeg(args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("ffmpeg", args, {
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`ffmpeg exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
174
src/config.ts
174
src/config.ts
@@ -1,74 +1,120 @@
|
||||
import { z } from "zod";
|
||||
import { ConfigError } from "./errors";
|
||||
|
||||
const configSchema = z.object({
|
||||
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
|
||||
VOICE_CHANNEL_ID: z.string().min(1).optional(),
|
||||
GUILD_ID: z.string().min(1).optional(),
|
||||
VERBOSE: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
RECORDINGS_DIR: z.string().default("./recordings"),
|
||||
RECORDING_SEGMENT_MS: z.coerce.number().positive().default(5000),
|
||||
DECODER_ROTATE_MS: z.coerce.number().positive().default(5000),
|
||||
DECODER_COOLDOWN_MS: z.coerce.number().positive().default(30000),
|
||||
WEBSERVER_PORT: z.coerce.number().positive().default(3000),
|
||||
VOICE_CONNECTION_TIMEOUT_MS: z.coerce.number().positive().default(15000),
|
||||
RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000),
|
||||
AUDIO_STREAM_SILENCE_DURATION_MS: z.coerce.number().positive().default(3000),
|
||||
PACKET_FILTER_MIN_SIZE: z.coerce.number().positive().default(8),
|
||||
OPUS_FRAME_SIZE: z.coerce.number().positive().default(960),
|
||||
AUDIO_SAMPLE_RATE: z.coerce.number().positive().default(48000),
|
||||
AUDIO_CHANNELS: z.coerce.number().positive().default(2),
|
||||
AVATAR_SIZE: z.coerce.number().positive().default(64),
|
||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
MONITOR_GUILD_ID: z.string().min(1).optional(),
|
||||
PICSER_UPLOAD_URL: z.string().url().default("https://picser.asepharyana.tech/api/upload"),
|
||||
ATTACHMENT_UPLOAD_TIMEOUT_MS: z.coerce.number().positive().default(30000),
|
||||
ATTACHMENT_MAX_SIZE_MB: z.coerce.number().positive().default(100),
|
||||
ATTACHMENT_RETRY_ATTEMPTS: z.coerce.number().positive().default(3),
|
||||
BACKLOG_SYNC_HOURS: z.coerce.number().positive().default(24),
|
||||
BACKLOG_SYNC_BATCH_SIZE: z.coerce.number().int().positive().max(100).default(100),
|
||||
AI_ANALYSIS_ENABLED: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
OPENAI_MODERATION_API_KEY: z.string().optional(),
|
||||
OPENAI_MODERATION_BASE_URL: z.string().url().default("https://api.openai.com/v1"),
|
||||
OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"),
|
||||
AI_LLM_API_KEY: z.string().optional(),
|
||||
AI_LLM_BASE_URL: z.string().url().default("https://9router.asepharyana.tech/v1"),
|
||||
AI_LLM_MODEL: z.string().default("free"),
|
||||
AI_ANALYSIS_TIMEOUT_MS: z.coerce.number().positive().default(30000),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (!value.AI_ANALYSIS_ENABLED) return;
|
||||
if (!value.OPENAI_MODERATION_API_KEY) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["OPENAI_MODERATION_API_KEY"],
|
||||
message: "OPENAI_MODERATION_API_KEY is required when AI_ANALYSIS_ENABLED=true",
|
||||
});
|
||||
}
|
||||
if (!value.AI_LLM_API_KEY) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["AI_LLM_API_KEY"],
|
||||
message: "AI_LLM_API_KEY is required when AI_ANALYSIS_ENABLED=true",
|
||||
});
|
||||
}
|
||||
});
|
||||
const configSchema = z
|
||||
.object({
|
||||
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
|
||||
VOICE_CHANNEL_ID: z.string().min(1).optional(),
|
||||
GUILD_ID: z.string().min(1).optional(),
|
||||
TEXT_GUILD_ID: z.string().min(1).optional(),
|
||||
TEXT_CHANNEL_ID: z.string().min(1).optional(),
|
||||
VOICE_GUILD_ID: z.string().min(1).optional(),
|
||||
VERBOSE: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
RECORDINGS_DIR: z.string().default("./recordings"),
|
||||
RECORDING_SEGMENT_MS: z.coerce.number().positive().default(5000),
|
||||
DECODER_ROTATE_MS: z.coerce.number().positive().default(5000),
|
||||
DECODER_COOLDOWN_MS: z.coerce.number().positive().default(30000),
|
||||
WEBSERVER_PORT: z.coerce.number().positive().default(3000),
|
||||
VOICE_CONNECTION_TIMEOUT_MS: z.coerce.number().positive().default(15000),
|
||||
RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000),
|
||||
AUDIO_STREAM_SILENCE_DURATION_MS: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(3000),
|
||||
PACKET_FILTER_MIN_SIZE: z.coerce.number().positive().default(8),
|
||||
OPUS_FRAME_SIZE: z.coerce.number().positive().default(960),
|
||||
AUDIO_SAMPLE_RATE: z.coerce.number().positive().default(48000),
|
||||
AUDIO_CHANNELS: z.coerce.number().positive().default(2),
|
||||
AVATAR_SIZE: z.coerce.number().positive().default(64),
|
||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
MONITOR_GUILD_ID: z.string().min(1).optional(),
|
||||
PICSER_UPLOAD_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://picser.asepharyana.tech/api/upload"),
|
||||
ATTACHMENT_UPLOAD_TIMEOUT_MS: z.coerce.number().positive().default(30000),
|
||||
ATTACHMENT_MAX_SIZE_MB: z.coerce.number().positive().default(100),
|
||||
ATTACHMENT_RETRY_ATTEMPTS: z.coerce.number().positive().default(3),
|
||||
BACKLOG_SYNC_HOURS: z.coerce.number().positive().default(24),
|
||||
BACKLOG_SYNC_BATCH_SIZE: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(100)
|
||||
.default(100),
|
||||
AI_ANALYSIS_ENABLED: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
OPENAI_MODERATION_API_KEY: z.string().optional(),
|
||||
OPENAI_MODERATION_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://api.openai.com/v1"),
|
||||
OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"),
|
||||
AI_LLM_API_KEY: z.string().optional(),
|
||||
AI_LLM_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://9router.asepharyana.tech/v1"),
|
||||
AI_LLM_MODEL: z.string().default("free"),
|
||||
AI_ANALYSIS_TIMEOUT_MS: z.coerce.number().positive().default(30000),
|
||||
DATABASE_TYPE: z.enum(["sqlite", "postgres"]).default("sqlite"),
|
||||
DATABASE_URL: z.string().optional(),
|
||||
POSTGRES_HOST: z.string().default("localhost"),
|
||||
POSTGRES_PORT: z.coerce.number().int().positive().default(5432),
|
||||
POSTGRES_USER: z.string().optional(),
|
||||
POSTGRES_PASSWORD: z.string().optional(),
|
||||
POSTGRES_DB: z.string().optional(),
|
||||
POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2),
|
||||
POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10),
|
||||
ADMIN_PASSWORD: z.string().default("admin123"),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value.AI_ANALYSIS_ENABLED) {
|
||||
// Continue to database validation
|
||||
} else if (!value.AI_LLM_API_KEY) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["AI_LLM_API_KEY"],
|
||||
message: "AI_LLM_API_KEY is required when AI_ANALYSIS_ENABLED=true",
|
||||
});
|
||||
}
|
||||
|
||||
export type AppConfig = z.infer<typeof configSchema>;
|
||||
// Validate PostgreSQL configuration
|
||||
if (value.DATABASE_TYPE === "postgres") {
|
||||
if (!value.DATABASE_URL && !value.POSTGRES_HOST) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["DATABASE_URL"],
|
||||
message:
|
||||
"Either DATABASE_URL or POSTGRES_HOST must be provided when DATABASE_TYPE=postgres",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof configSchema> & {
|
||||
EFFECTIVE_TEXT_GUILD_ID?: string;
|
||||
EFFECTIVE_VOICE_GUILD_ID?: string;
|
||||
};
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
try {
|
||||
return configSchema.parse(env);
|
||||
const parsed = configSchema.parse(env);
|
||||
return {
|
||||
...parsed,
|
||||
EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID,
|
||||
EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const messages = error.issues
|
||||
|
||||
108
src/database/drizzle.ts
Normal file
108
src/database/drizzle.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3";
|
||||
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import { config } from "../config";
|
||||
import { createChildLogger } from "../logger";
|
||||
import * as schema from "./schema";
|
||||
|
||||
const logger = createChildLogger("drizzle");
|
||||
|
||||
let db:
|
||||
| ReturnType<typeof drizzlePostgres>
|
||||
| ReturnType<typeof drizzleSqlite>
|
||||
| null = null;
|
||||
|
||||
/**
|
||||
* Initialize the database connection based on DATABASE_TYPE config
|
||||
* Supports both PostgreSQL and SQLite
|
||||
*/
|
||||
export async function initializeDatabase() {
|
||||
if (db !== null) {
|
||||
return db;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Use DATABASE_URL if available, otherwise build from individual variables
|
||||
if (config.DATABASE_URL) {
|
||||
pool = new Pool({
|
||||
connectionString: config.DATABASE_URL,
|
||||
min: config.POSTGRES_POOL_MIN,
|
||||
max: config.POSTGRES_POOL_MAX,
|
||||
});
|
||||
} else {
|
||||
pool = new Pool({
|
||||
host: config.POSTGRES_HOST,
|
||||
port: config.POSTGRES_PORT,
|
||||
user: config.POSTGRES_USER,
|
||||
password: config.POSTGRES_PASSWORD,
|
||||
database: config.POSTGRES_DB,
|
||||
min: config.POSTGRES_POOL_MIN,
|
||||
max: config.POSTGRES_POOL_MAX,
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
} else {
|
||||
const sqlite = new Database(".muxer-queue.db");
|
||||
sqlite.pragma("journal_mode = WAL");
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the initialized database instance
|
||||
* Throws if database has not been initialized
|
||||
*/
|
||||
export function getDatabase() {
|
||||
if (db === null) {
|
||||
throw new Error(
|
||||
"Database not initialized. Call initializeDatabase() first.",
|
||||
);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
* For PostgreSQL, the pool will close on process exit
|
||||
* For SQLite, closes the database connection
|
||||
*/
|
||||
export async function closeDatabase() {
|
||||
if (db === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.DATABASE_TYPE === "postgres") {
|
||||
logger.info("PostgreSQL connection pool will close on process exit");
|
||||
} else {
|
||||
logger.info("SQLite database closed");
|
||||
}
|
||||
|
||||
db = null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user