Merge branch 'fix/remove-voice-recording'

This commit is contained in:
asepharyana
2026-09-23 22:13:00 +07:00
141 changed files with 410 additions and 25780 deletions
-25
View File
@@ -18,31 +18,12 @@ MONITOR_GUILD_ID=your_guild_id_here # Target guild for text monitoring
TEXT_GUILD_ID=optional_text_guild_id # Override text capture guild (falls back to MONITOR_GUILD_ID)
TEXT_CHANNEL_ID=optional_text_channel_id # Restrict text capture to a single channel
# === Voice Channels ===
# VOICE_GUILD_ID= # Guild for voice connection (optional)
# VOICE_CHANNEL_ID= # Channel for voice connection (optional)
# === Recording ===
RECORDINGS_DIR=./recordings # Audio file output directory (default: ./recordings)
RECORDING_SEGMENT_MS=5000 # OGG segment duration in ms (default: 5000)
# === Decoder ===
DECODER_ROTATE_MS=5000 # Opus decoder rotation interval in ms (default: 5000)
DECODER_COOLDOWN_MS=30000 # Decoder error cooldown in ms (default: 30000)
# === Audio ===
AUDIO_STREAM_SILENCE_DURATION_MS=3000 # Silence threshold in ms before stopping stream (default: 3000)
PACKET_FILTER_MIN_SIZE=8 # Minimum Opus packet size in bytes (default: 8)
OPUS_FRAME_SIZE=960 # Opus frame size in samples (default: 960)
AUDIO_SAMPLE_RATE=48000 # Audio sample rate in Hz (default: 48000)
AUDIO_CHANNELS=2 # Number of audio channels (default: 2)
AVATAR_SIZE=64 # User avatar size in pixels (default: 64)
# === Webserver ===
WEBSERVER_PORT=4001 # Backend HTTP/WS server port (default: 4001)
# === Connection ===
VOICE_CONNECTION_TIMEOUT_MS=15000 # Voice connection timeout in ms (default: 15000)
RECONNECT_TIMEOUT_MS=5000 # Reconnect timeout in ms (default: 5000)
# === Logging ===
@@ -69,11 +50,6 @@ POSTGRES_POOL_MAX=10 # Maximum pool connections (default: 10)
# === Redis ===
REDIS_URL=redis://100.121.180.82:6379 # Redis connection string (default: redis://localhost:6379)
# === Voice PCM WebSocket (direct gateway→backend, bypasses Redis) ===
VOICE_PCM_WS_ENABLED=true # Use direct WS for PCM audio (default: true)
BACKEND_WS_URL=ws://backend:4001/ws # Backend WebSocket URL for gateway PCM streaming
BACKEND_WS_TOKEN= # REQUIRED if VOICE_PCM_WS_ENABLED=true. Internal shared secret
# === Attachments ===
TELE_UPLOAD_URL=https://upload.asepharyana.my.id/api/upload # Attachment upload endpoint (default)
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000 # Upload timeout in ms (default: 30000)
@@ -132,7 +108,6 @@ AUTO_DELETE_LOG_CHANNEL_ID= # Channel ID to log auto-delete actions
# === Retention (0 = disabled) ===
RETENTION_MESSAGES_DAYS=0 # Message retention in days (default: 0 = off)
RETENTION_ATTACHMENTS_DAYS=0 # Attachment retention in days (default: 0 = off)
RETENTION_VOICE_DAYS=0 # Voice recording retention in days (default: 0 = off)
RETENTION_CLEANUP_INTERVAL_MS=86400000 # Cleanup interval in ms (default: 24h)
RETENTION_DRY_RUN=true # Dry-run: log but do not delete (default: true)
+1 -1
View File
@@ -1,6 +1,6 @@
# GMW — Agent Guide
GMW (Guild Moderation Watcher) is a Discord bot + web dashboard for AI-powered moderation. A monorepo with three services: a selfbot gateway that captures messages/voice and runs LLM moderation, an Express/oRPC backend that serves the dashboard API, and a Next.js 16 SSR frontend. They communicate via Redis pub/sub (gateway→backend) and WebSocket (backend→browser).
GMW (Guild Moderation Watcher) is a Discord bot + web dashboard for AI-powered moderation. A monorepo with three services: a selfbot gateway that captures Discord events and runs LLM moderation, an Express/oRPC backend that serves the dashboard API, and a Next.js 16 SSR frontend. They communicate via Redis pub/sub (gateway→backend) and WebSocket (backend→browser).
## Quick reference
@@ -1,67 +0,0 @@
# Spec: Perbagus fitur Voice + Audio Playback (GMW frontend)
Tanggal: 2026-08-22 · Scope: **frontend only** (backend/gateway API sudah cukup)
## Masalah (audit)
1. Recordings: semua kartu pakai `<audio controls>` native — tampilan identik,
tidak ada indikasi which-clip-playing / loading / paused, dan N audio bisa
play bareng (overlap).
2. Media view: `thumbnailUrl` dari gateway tidak dipakai; tidak ada visual
"sedang playing" selain disc spin; queue item semua sama tanpa badge up-next.
3. Mini-player (`lib/hooks/use-media-player.tsx`) ada tapi TIDAK PERNAH
dimount → dead code, user tidak lihat status musik di halaman lain.
4. Voice page: `useMicTransmit.setVolume` + `useVoiceListen.setVolume`
tersedia tapi tak ada UI-nya; mic live tidak punya level feedback.
## Desain
### A. RecordingAudioPlayer (baru, `components/voice/recording-audio-player.tsx`)
Custom player menggantikan `<audio controls>`:
- Play/pause button (ikon berubah), spinner saat buffering (`waiting` event).
- Progress bar seekable (click-to-seek) + time label `m:ss / m:ss`.
- Waveform-ish equalizer bars saat playing (CSS animation, reduced-motion safe).
- **Single-playback**: module-level registry `activePlayers` — memainkan satu
clip otomatis pause yang lain.
- Kartu pemilik player aktif dapat highlight border signal + "Now playing" chip.
### B. Recordings view — pasang player baru
- Ganti `<audio>` → `<RecordingAudioPlayer src download_url>`.
- Highlight kartu via state lifted: `playingId` di view, callback `onPlay`.
### C. Media view polish
- Hero: thumbnail (jika `current.thumbnailUrl`) sebagai disc center image;
fallback ListMusic icon. Equalizer bars animasi CSS saat `playing`.
- Queue row pertama: badge "up next"; baris current track diberi ring signal.
- Volume read-only tetap.
### D. MiniPlayer global
- Hapus `lib/hooks/use-media-player.tsx` (dead) — ganti dengan komponen
`components/media/mini-player.tsx` yang subscribe `useMediaState` +
`useMediaWsSync` langsung (SWR cache shared antar route), mounted di
`AppFrame` bawah layar (fixed bottom, hidden di route `/media`).
- Menampilkan: thumbnail kecil/judul, tombol skip/stop, link ke /media.
### E. Voice UI
- Mic live: level meter (Equalizer bars) — mic-transmitter sudah punya worklet;
tambah `getLevel()` via AnalyserNode pada stream (simple RMS) di hook.
- Listen: volume slider (input range) wired ke `listen.setVolume`.
- Mic volume slider wired ke `mic.setVolume`.
## File touched
| File | Aksi |
|---|---|
| services/frontend/src/components/voice/recording-audio-player.tsx | new |
| services/frontend/src/app/(dashboard)/recordings/view.tsx | edit |
| services/frontend/src/app/(dashboard)/media/view.tsx | edit |
| services/frontend/src/components/media/mini-player.tsx | new |
| services/frontend/src/components/shell/ambient-app.tsx | mount MiniPlayer |
| services/frontend/src/lib/hooks/use-media-player.tsx | delete |
| services/frontend/src/hooks/use-voice.ts | tambah micLevel |
| services/frontend/src/lib/audio/mic-transmit.ts | expose analyser level |
| services/frontend/src/app/(dashboard)/voice/view.tsx | sliders + meter |
## Verifikasi
1. `pnpm lint` (biome) + `pnpm build` clean.
2. Smoke di port **4024** (BUKAN 4017) → curl 200 semua route.
3. Commit (tanpa trailer) → push → `gh run watch` → live check
https://imphnen.asepharyana.my.id/{media,recordings,voice}/ = 200.
@@ -1,82 +0,0 @@
# Spec: Recordings — filter per user + export WAV (Audacity)
## Konteks / Gejala
Halaman `services/frontend/src/app/(dashboard)/recordings` menampilkan semua
rekaman voice (deck). User ingin:
1. **Filter per orang** (tampil rekaman satu user saja).
2. **Export ke format untuk Audacity** (buka & edit rekaman di Audacity).
## Fakta saat ini (verified)
- Backend `recordings.list` (services/backend/src/orpc/router.ts:291) SUDAH
menerima `userId`/`channelId` filter → `RecordingsService.getRecent`.
- Frontend `recordingsApi.list(limit, channelId, userId, cursor)` (lib/api/recordings.ts)
sudah meneruskan `userId`. `useLoadMoreRecordings` juga sudah bawa userId.
- Tapi UI `RecordingsView` (app/(dashboard)/recordings/view.tsx) TIDAK punya
filter UI, dan `useRecordingsPage` dipanggil tanpa userId → semua tampil.
- Setiap rekaman punya `download_url` (MP3 di TeleUploader), `user_id`, `username`.
- Audacity membuka MP3/OGG tapi editing paling bersih dari WAV (uncompressed)
/ FLAC (lossless). Backend TIDAK punya ffmpeg & Nix flake backend tak include
ffmpeg → transcode server-side bukan pilihan. Browser punya codec MP3 → export
WAV via Web Audio API (client-side) adalah solusi self-contained terbaik.
## Keputusan desain
1. **Filter per user**: UI dropdown (Semua User + per user) di header halaman.
Memilih user → re-fetch `recordingsApi.list(50, undefined, userId)` (server
filter, benar untuk dataset besar + pagination). Dropdown dibangun dari
distinct `user_id`/`username` pada items yang sedang tampil.
2. **Export WAV (Audacity)**: client-side via Web Audio API.
- Per kartu: tombol "WAV" → decode `download_url` → WAV 16-bit PCM → download.
- Header: tombol "EXPORT WAV (N)" → gabung (concat) semua rekaman yang
sedang tampil (ter-filter) jadi 1 file WAV → download. Ideal untuk analisis
/ mixdown per orang.
- Implementasi di `lib/audio/wav.ts` (decode + encode + concat), tanpa dep baru.
## Perubahan
### Frontend
- **`src/lib/audio/wav.ts`** (baru):
- `decodeAudio(url: string): Promise<AudioBuffer>` — fetch arrayBuffer →
`new AudioContext().decodeAudioData`.
- `audioBufferToWav(buf: AudioBuffer, sampleRate=48000): Blob` — PCM 16-bit
interleaved, mono→stereo handling, RIFF/WAVE writer. Audacity-importable.
- `concatBuffers(buffers: AudioBuffer[]): AudioBuffer` — gabung di channel 0
(mono) dengan sample-rate max; untuk export gabungan.
- `downloadWav(blob: Blob, filename: string): void` — obj URL + <a download>.
- **`src/app/(dashboard)/recordings/view.tsx`**:
- Toolbar filter: dropdown user (built from distinct items) + tombol reset.
- State `filterUserId`; saat berubah → `recordingsApi.list(50, undefined, id)`
→ set ke SWR (key includes filter), reset pagination.
- Tombol "WAV" per kartu (disabled jika `!r.download_url`).
- Tombol "EXPORT WAV (N)" di header (disabled jika 0 item punya download_url);
concat semua items ter-filter yang punya download_url.
- Status loading saat export (spinner/disable).
- **`src/hooks/use-recordings.ts`**: `useRecordingsPage` menerima `userId?` dan
memasukkan ke key + call, supaya filter re-fetch bersih (per-user cache key).
`useRecordings`/`useLoadMoreRecordings` propagate `userId`.
- **`src/lib/types/recording.ts`**: tidak berubah (userId dari items).
### Backend / gateway
- Tidak ada perubahan. Filter & export sepenuhnya frontend.
## File yang disentuh (frontend only)
- `src/lib/audio/wav.ts` (baru)
- `src/app/(dashboard)/recordings/view.tsx`
- `src/hooks/use-recordings.ts`
## Verification
1. `cd services/frontend && pnpm typecheck` (tsc --noEmit) — 0 error.
2. `pnpm lint` (biome check src/) — exit 0.
3. `pnpm build` (next build) — hijau.
4. Manual (user): buka /recordings; pilih user di dropdown → hanya rekaman user
itu; klik WAV di kartu → file .wav ter-download & terbuka di Audacity; klik
EXPORT WAV (filtered) → satu .wav gabungan.
5. Push → CI `Build & Deploy (Nix)` (frontend job) hijau → deploy landing.
## Risiko / Trade-off
- Web Audio decode MP3 di client: butuh CORS pada download_url (TeleUploader
asepharyana.my.id — sudah same-serve/proxied, CORS ikut origin). Jika 403/CORS
gagal, error toaster + fallback manual (RAW MP3 tetap ada).
- concat gabungan = mono 48k; Audacity bisa edit per-channel nanti. Acceptable.
- Filter client (dropdown dari items yang dimuat) hanya menawarkan user yang
sudah tampil; dataset besar bisa pakai search nanti. Server filter benar untuk
yang dipilih.
@@ -1,81 +0,0 @@
# Spec: Recordings v2 — transcription, search, filters, leaderboard, sessions
## Konteks
4 fitur lanjutan untuk halaman /recordings (dipilih user):
1. Tampilkan transkripsi + search by kata kunci
2. Filter lanjutan: by channel + rentang tanggal
3. Kelompokkan klip jadi "sesi rapat" + autoplay berurutan + export satu sesi
4. Leaderboard bicara per user + ringkasan
## Fakta terverifikasi (2026-08-30)
- `voice_recordings` kolom: id, user_id, username, avatar_url, guild_id,
channel_id, channel_name, filename, size_bytes, download_url, upload_status,
upload_error, created_at, uploaded_at, **transcription** (schema
`shared/database/schema.ts:246`). Index user_id/channel_id/created_at.
- **Transcription 0/10.640** terisi prod: `AI_VOICE_TRANSCRIPTION_ENABLED`
default **false** (config/index.ts:333) & tidak diset di BWS env →
`transcribeRecording` (voiceTranscriber.ts) langsung return null.
`AI_LLM_BASE_URL` + `AI_LLM_API_KEY` SUDAH dikonfig (Whisper via router GMW).
- Transcriber hardcode `language: "en"` (voiceTranscriber.ts:34) — salah utk
ucapan campur id/en. Utk auto-detect: hapus param `language` (Whisper
auto-detect jika tidak diberikan).
- Backend `RecordingsService.getRecent` (recordings.service.ts) TIDAK select
`transcription`; SUDAH dukung filter `channelId`+`userId`+`cursor`; belum
dukung date-range & keyword search. `RecordingRow` interface juga tak punya
`transcription`.
- Tidak ada kolom `session_id` / `duration_ms` → sesi grouping = heuristik
(channel sama + gap created_at), durasi leaderboard = estimasi dari
size_bytes (MP3 128kbps: durasi_s ≈ size_bytes*8/128000).
## Keputusan desain
1. **Aktifkan transkripsi (fondasi)**: transcriber auto-detect (hapus
`language:"en"`), set secret BWS `AI_VOICE_TRANSCRIPTION_ENABLED=true`
(dibaca runtime oleh bws-exec saat service start). Rekaman BARU dapat
transkripsi. Backfill rekaman lama TIDAK dilakukan (pilih user: fokus baru;
file OGG lama kemungkinan besar sudah tidak dipakai).
2. **Backend** — perluas `getRecent`:
- select `transcription` (+ interface RecordingRow + FE type)
- filter baru: `q` (ILIKE on transcription + username), `startDate`/`endDate`
(created_at range, bigint ms)
- endpoint baru `recordings.summary`: agregasi per user → {user_id,
username, avatar_url, clips, est_duration_s, words, last_at}. `words`
dihitung dari transcription (tokenisasi spasi). Return sorted by clips.
3. **Frontend**:
- Kartu: tampilkan transkripsi (collapse/expand line-clamp) + durasi estimasi.
- Toolbar: search box (q), Select channel, date range (start/end), speaker
(sudah ada), reset filter.
- Tab/segment "Tape Deck" vs "Leaderboard": leaderboard render summary per
user + klik → filter deck by user itu.
- Sesi grouping (deck view): klip di-group jadi sesi bila channel sama &
gap antar klip < SESSION_GAP_MS (default 120s). Header sesi (channel,
waktu mulai, jumlah klip, total durasi). Autoplay tombol "Play session" &
"Export session WAV" (concat klip sesi via lib/audio/wav.ts yg sudah ada).
## Perubahan file
### Gateway (Tahap 1)
- `voiceTranscriber.ts`: hapus baris `language: "en"`.
- (secret) set `AI_VOICE_TRANSCRIPTION_ENABLED=true` via bws.
### Backend (Tahap 2)
- `recordings.service.ts`: interface + select + getRecent tambah transcription;
tambah filter q/startDate/endDate; method getSummary() untuk leaderboard.
- `orpc/router.ts`: procedur `recordings.list` schema tambah fields; prosedur
baru `recordings.summary`.
### Frontend (Tahap 3-5)
- `lib/types/recording.ts`: tambah transcription, est_duration_s opsional,
Summary type.
- `lib/api/recordings.ts`: list tambah q/startDate/endDate; + summary().
- `hooks/use-recordings.ts`: propagate filter baru ke key+call; hook
useRecordingsSummary.
- `app/(dashboard)/recordings/view.tsx`: toolbar search+channel+date, kartu
transkripsi, tab leaderboard, grouping sesi + autoplay + export sesi.
## Verification
- tiap tahap: gateway `pnpm build` + `biome check src/`; backend `pnpm build`
+ `biome check src/ tests/`; FE `pnpm build` + `biome check src/`.
- CI Build & Deploy hijau tiap tahap; deploy landing dicek via
`systemctl show gmw-<svc>.service --property=ActiveEnterTimestamp`.
- Tahap 1c: setelah deploy + rekaman baru, cek
`SELECT COUNT(*) FROM voice_recordings WHERE transcription IS NOT NULL`.
@@ -1,71 +0,0 @@
# Spec: Record video (kamera/screenshare) orang lain — WebRTC receive (Request 2)
Date: 2026-08-30. Status: Phase A + B DONE (capture → playable MP4); Phase C (UI) open.
## Why this is hard (ground truth, verified from @discordjs/voice 0.19.2 source)
`VoiceReceiver.onUdpMessage` (dist/index.mjs:2059) drops EVERY non-opus RTP
packet at line 2068: `if ((msg[1] & 127) !== RTP_OPUS_PAYLOAD_TYPE) return;`.
So video (kamera H264? actually Discord uses VP8/H264; screenshare combines with
video SSRC) is decrypted-capable but never forwarded. `receiver.parsePacket`
(2033) DOES decrypt any payload type generically (audio + video) using
`connectionData.{encryptionMode, nonceBuffer, secretKey}` — the only audio gate
is the opus check inside onUdpMessage.
=> FIX: wrap `receiver.onUdpMessage` (like screenShareAudio.ts already does for
screen-share AUDIO SSRCs): for packets whose payload type is a VIDEO type
(payload 96 VP8, 101/102 H264, 106/116/126/127 AV1, VP9 98...), call
`receiver.parsePacket(...)` myself to decrypt, then depacketize + write frames.
Delegate opus (120) to the original handler. Delegate audio to original.
## Audio already works (screenShareAudio.ts). We add VIDEO.
## Science-of-the-changes below.
## Phase A — capture + decrypt + depacketize to AnnexB h264 (THIS change)
Files (new): `src/modules/voice-recording/videoReceiver.ts`
- Hook into `recorder.startRecording` alongside `hookScreenShareAudio`.
- Wrap `receiver.onUdpMessage`:
- read ssrc = msg.readUInt32BE(8); userData = receiver.ssrcMap.get(ssrc)
- if payload type is video AND we have a "watching" subscription for that user
(videoSSRC present), decrypt via receiver.parsePacket(...), then:
- H264 (101/102 + payload 120 not): strip RTP header, reassemble FU-A
fragments into AnnexB NALs (start-code prefixed), buffer until we have
a full access unit (keyframe SPS/PPS/IDR or slices), append to a per-
user-per-burst `.h264` file.
- else delegate to original onUdpMessage.
- Watch `receiver.ssrcMap` "create"/"update" for `videoSSRC !== undefined` →
signal a video burst started for that user (like screenShareAudio does).
- Per-user video files written to `config.RECORDINGS_DIR/<uid>/video-<ts>.h264`.
- Guard: skip bot's own video (client.user.id).
Dependencies: NO new npm deps for Phase A (only crypto already in
@discordjs/voice via parsePacket + Buffer). ffmpeg-headless (already in Nix
buildInputs) used in Phase B for decode+mux.
## Phase B — decode + mux to playable MP4/WebM (DONE, commit 999c054b)
- `closeBurst` waits for the WriteStream `finish` (full flush/fd close), then
`muxToMp4(rawPath)`: `ffmpeg -f h264 -i raw.h264 -c copy -movflags +faststart
out.mp4`, deletes raw on success (>=1B mp4), keeps it on failure.
- Output: `<RECORDINGS_DIR>/<uid>/video-<ssrc>-<ts>.mp4`.
- ffmpeg is on the gateway runtime PATH (pkgs.ffmpeg-headless, already in the
Nix buildInputs for the music/GoLive players).
- Test: `tests/videoReceiver.test.ts` muxToMp4 case (real ffmpeg, generates a
tiny baseline h264, asserts mp4 non-empty + raw deleted; skipped if no ffmpeg).
## Phase C — frontend playback + session grouping (follow-up)
- Backend oRPC list video files; FE video player, group by call session like audio.
## Verification
- Phase A: join voice, have a member screen-share/camera, confirm `.h264` file
grows with NAL frames + keyframes; journal shows "video burst" logs.
- Run vitest unit: RTP header strip + FU-A reassembly gives correct bytes.
## Open questions / risks
- Discord codec for camera = H264(101/102); screenshare uses H264 (101/103?)
and can also be VP8/VP9. Handle H264 first (depacketize proven), VP8/VP9 in
Phase B via ffmpeg RTP input.
- Encryption: DAVE (dave_protocol_version) adds a session layer; parsePacket
already applies daveSession.decrypt for audio — we must call the SAME
parsePacket path so DAVE/encryption is handled identically.
- ssrc↔user mapping during a broadcast: videoSSRC is in ssrcMap after the
voice state; may need the STREAM_CREATE network events to key reliably.
@@ -1,66 +0,0 @@
# Spec: Voice auto-reconnect (persistent state + rejoin on drop)
## Goal
Setelah `VoiceController.connect()` berhasil, state "sedang merekam di <guild>/<channel>"
disimpan di Postgres. Kalau gateway restart/reboot, atau koneksi voice drop tidak
disengaja (dikeluarkan/moved/server restart), gateway otomatis join ulang ke channel
yang sama.
## Requirement mapping (user's ask)
- "autoreconnect ke channel yg sama jika server restart atau reboot" → reconnect on
startup (ready handler) + keep DB record across graceful shutdown.
- "state nya persistent di db" → `voice_auto_reconnect` table.
- "rejoin jika tidak sengaja dikeluarkan" → watchdog on `Disconnected`/`Destroyed`
(kick / moved / voice server restart) → full rejoin with backoff.
- Manual leave (`/voice disconnect`, dashboard disconnect) MUST NOT rejoin.
## Design decisions
1. **New table** `voice_auto_reconnect` (dedicated, not `ui_state`):
- `guild_id` text PK
- `channel_id` text NOT NULL
- `channel_name` text
- `connected_at` bigint epoch-ms
- `updated_at` bigint epoch-ms
DAO: `voiceAutoReconnectRepo.ts` — `upsert(record)`, `list()`, `delete(guildId)`.
2. **Write on connect**: `VoiceController.connect()` → after `startRecording` success →
`upsert`. IDEMPOTENT (upsert per guild).
3. **Clear on manual leave**: `handleVoiceDisconnect` (all) + `handleVoiceDisconnectGuild`
pass `clearPersisted: true`. Graceful shutdown `disconnect()` keeps the record.
4. **Rejoin on startup**: bootstrap `ready` → `await voiceController.autoReconnect()`
(list persisted → connect each, non-fatal on failure).
5. **Rejoin on unexpected drop**: monitor per-connection; on `Disconnected`/`Destroyed`
with `!intentional` → schedule rejoin `connect(guildId, persistedChannelId)` with
backoff (min 2s, max 30s, max 5 attempts). Track `rejoinAttempts`, reset on success.
6. **Intentional flag**: `disconnectGuild(guildId, { clearPersisted?, intentional? })`.
- shutdown `disconnect()` → `{ intentional: true, clearPersisted: false }`.
- manual `disconnect()` (dashboard) → `{ clearPersisted: true }`, sets intentional.
- manual `disconnectGuild` → `{ clearPersisted: true }`, sets intentional.
The monitor checks `intentional` before rejoin; `clearPersisted` only deletes the row.
## Files touched
- `services/discord-gateway/src/shared/database/schema.ts` — add `pgVoiceAutoReconnectTable`
+ types.
- `services/discord-gateway/src/shared/database/voiceAutoReconnectRepo.ts` (NEW) — DAO.
- `services/discord-gateway/src/modules/voice-recording/voiceController.ts` — upsert on
connect; monitor + rejoin; `autoReconnect()`; `disconnect/disconnectGuild` opts.
- `services/discord-gateway/src/modules/command-handler/voice.handler.ts` — manual
disconnect/disconnectGuild pass `clearPersisted: true`.
- `services/discord-gateway/src/app/bootstrap.ts` — call `voiceController.autoReconnect()`
in `ready`.
- `services/discord-gateway/drizzle/migrations/0020_add_voice_auto_reconnect.sql` +
`meta/_journal.json` entry (apply manually per gmw-ops).
## Edge cases
- Channel deleted / guild lost while persisted → `connect()` throws (channel not found)
→ log + delete persisted row (don't retry forever).
- Rejoin attempts exhausted → keep row (so next restart retries) + log.
- Multiple guilds: per-guild monitor, per-guild persisted row.
- Graceful shutdown order: shutdown sets intentional=true (so no rejoin during teardown)
but keeps row.
## Verification
- `pnpm typecheck && pnpm build && pnpm lint` in `services/discord-gateway`.
- Apply migration `0020` manually; verify table exists.
- CI `Build & Deploy (Nix)` green; gateway deploy lands.
- Manual: connect via dashboard → check `voice_auto_reconnect` row; simulate drop →
confirm rejoin; manual disconnect → row cleared.
@@ -1,166 +0,0 @@
# Spec: Perbaiki alur voice → recording (miss & terpotong)
## Konteks & Gejala
User melaporkan alur voice sampai recording **banyak miss** (audio tidak tercatat)
dan **terpotong** (satu alur bicara kebelah jadi beberapa segmen / audio putus di
tengah). Ini domain `services/discord-gateway/src/modules/voice-recording/`.
Pipeline per user yang mulai bicara (speaking "start"):
```
receiver.speaking "start" → speakingHandler(userId)
├─ await collectUserMetadata(...) ← roundtrip API, subscribe tertunda
├─ receiver.subscribe(userId, {end: AfterSilence, duration: 3000ms}) → audioStream
├─ attach data/end/error handlers → audioStream.pipe(PacketFilter) → oggPacketStream
├─ SegmentManager.open() → OggLogicalBitstream → file .ogg
├─ data: SegmentManager.rotateIfNeeded (rotasi 5s) + decoder.write (web PCM tho
└─ end: SegmentManager.close() → segmen finish → finalizeSegment upload + transkrip
```
## Root cause (dari pembacaan kode — justifikasi di bawah)
### A. MISS bagian awal bicara — subscribe tertunda (utama)
`speakingHandler.ts:53` melakukan `await collectUserMetadata(...)` SEBELUM
`receiver.subscribe`. `collectUserMetadata` (metadata.ts:30) pada cold path
(cache miss) melakukan `client.users.fetch` + `guild.members.fetch` roundtrip
Discord API (ratusan ms–detik). Selama await, seluruh opus awal bocor → awal
kalimat hilang. Cache menghilangkan ini untuk user yang pernah ter-record, tapi
user baru/evict (cache max 200) kena setiap kali.
### B. Double-subscribe race
Guard `receiver.subscriptions.has(userId)` di `speakingHandler.ts:63` diletakkan
SETELAH `await collectUserMetadata`. Dua event "start" cepat keduanya melewati
guard (belum subscribe) → dua subscription → audio terbelah/ganda per user.
### C. TERPOTONG di jeda bicara — AfterSilence 3000ms
`AUDIO_STREAM_SILENCE_DURATION_MS=3000`. Setelah 3s diam, stream auto-`end` →
`SegmentManager.close` → segmen baru saat bicara lagi. Jeda normal (berpikir,
interupsi) memecah 1 alur bicara jadi beberapa segmen/file. Ini source "terpotong".
Segmen pendek hasil jeda <1s juga DIBUANG di `finalizeSegment` (MIN_DURATION_MS=1000)
→ miss kata singkat ("ya", "siap").
### D. Rotasi segmen 5s di tengah bicara
`RECORDING_SEGMENT_MS=5000`: `rotateIfNeeded` menutup bitstream & membuka baru
setiap 5s walau bicara kontinu. Pipenya di-re-wire di dalam handler data →
window drop kecil + continuity file pecah (bukan masalah besar, tapi berkontribusi).
### E. Tidak ada sinkronisasi "stop" speaking & stream "end" flaky
Handler hanya listen "start"; mengandalkan `AfterSilence` untuk emit "end".
Bug @discordjs/voice yang dikenal: `AfterSilence` bisa TIDAK emit "end" saat
koneksi gagal/teardown → segmen menggantung & tidak pernah finalize/upload
(recording "hilang"). Tidak ada watchdog.
## Scope
Hanya `services/discord-gateway/src/modules/voice-recording/` (+ config index bila
perlu default baru). Tidak menyentuh playback (player.ts), transmitter (voice dari
browser → Discord, arah berlawanan), muxer (konsolidasi akhir), atau screen-share
video (hanya audio SSRC via `hookScreenShareAudio` sudah ada & dibiarkan).
## Perubahan
### 1. Speak-before-metadata: subscribe LEBIH DULU, metadata paralel
`recorder/speakingHandler.ts`:
- Pindahkan `receiver.subscribe` + pipeline setup ke ATAS, SEGERA di handler,
sebelum `collectUserMetadata`.
- Jalankan `collectUserMetadata` secara paralel non-blocking; gunakan metadata
cache untuk registrasi segmen saat finalize.
- Pertahankan guard skip bot/user (bot bisa dicek dari `client.users.cache` /
`client.user.id` tanpa await) SEBELUM subscribe — jangan tunggu fetch user.
Detail konkret:
```
async handler(userId):
if (userId === client.user?.id) return;
if (receiver.subscriptions.has(userId)) return; // guard kini di DEPAN, tanpa await
// (belum tahu bot? gunakan cache user; subscribe dulu biar nggak miss)
clone = subscribe(userId, {AfterSilence, duration}) // TANPA await metadata
setup pipeline (data/end/error, pipe, open segment)
collectUserMetadata(...).then(meta => {
if (meta.bot) { drain & close subscription (jangan simpan) }
else { registrasi ulang metadata utk segmen aktif }
})
```
Karena listener `start` dibuang untuk bot, harus tutup subscription bot tanpa
menyimpan segmen (buang hasil). Pakai `receiver.subscriptions.get(userId)?.destroy()`.
### 2. Selesaikan race double-subscribe (bagian dari #1)
Guard `receiver.subscriptions.has(userId)` diletakkan SINCRON di awal (sebelum
await). Karena `subscribe` sinkron dan `subscriptions` terisi sinkron saat
dipanggil, event "start" kedua yang tiba setelah subscribe akan melihat
subscription aktif → di-skip. Tidak ada await antara guard & subscribe.
### 3. Naikkan AfterSilence + tail-length → kurangi "terpotong"
`shared/config/index.ts`:
- `AUDIO_STREAM_SILENCE_DURATION_MS` default 3000 → 4000 (beri ruang jeda
alami; Discord packet 20ms, 4s masih wajar, tidak membengkak file).
Opsional via env override di production (tidak wajib komit env).
### 4. Segmen berorientasi "burst bicara" daripada rotasi jam
`recorder/segment.ts` + `recorder/speakingHandler.ts`:
- Hapus/lepas rotasi SEGMEN berbasis waktu (RECORDING_SEGMENT_MS). Alih-alih,
satu segmen = satu burst bicara (buka di "start", tutup di "end"/AfterSilence
end). Ini menghilangkan pemecahan di tengah kalimat.
- `RECORDING_SEGMENT_MS` tetap dipakai untuk rotasi decoder web-PCM (broadcast
live), di mana segmen besar bisa menunda frame — biarkan seperti ada.
JADI: `SegmentManager.rotateIfNeeded` TIDAK lagi dipanggil pada jalur OGG
recording; decoder rotate tetap dijalankan.
Catatan: dengan satu segmen per burst, ukuran file ~ durasi bicara. File panjang
dibutuhkan transkrip & transcode; tidak ada batas keras yang perlu di-override.
Watchdog di #5 membatasi durasi menggantung.
### 5. Watchdog end-of-burst & teardown recovery
`recorder/speakingHandler.ts`:
- Setelah subscribe, arm timer watchdog (mis. `config twin`/hitung) yang menutup
segmen jika `AfterSilence` tidak emit "end" dalam X detik setelah "stop"
speaking — atau, lebih sederhana & robust: dengarkan BOTH stream "end" DAN
timer dari `receiver.speaking` "stop" (hingga @discordjs/voice meng-klaim
AfterSilence). Bila "stop" fire, mulai countdown kecil (mis. 500ms) lalu
`segmentManager.close` + `decoder.destroy` + destroy subscription jika stream
belum "end".
- Ini menutup A: segmen menggantung → jadi pasti finalize & upload.
Implementasi: subscriptionStream (audioStream) + track milik per-user di
Map<userId, {audioStream, segmentManager, decoder, timer}>; handler "stop"
menjadwalkan finalize.
### 6. Naikkan/ambil MIN segmen duration lebih rendah
`recorder/segmentFinalizer.ts`: MIN_DURATION_MS 1000 → 300ms. Kata pendek
("ya", "siap") tetap tersimpan. GUI biarkan.
## File yang disentuh
- `src/modules/voice-recording/recorder/speakingHandler.ts` (utama: subscribe
first, guard depan, watchdog stop, hapus rotasi segmen dari jalur OGG)
- `src/modules/voice-recording/recorder/segment.ts` (opsional: API close/open,
pertahankan rotate untuk decoder tapi tak dipakai jalur OGG)
- `src/modules/voice-recording/recorder/streamSetup.ts` (kecil: backfill subscribe
supaya return subscription utk cleanup/destroy bot)
- `src/modules/voice-recording/recorder/segmentFinalizer.ts` (MIN_DURATION)
- `src/shared/config/index.ts` (default AfterSilence 4000)
## Yang TIDAK disentuh
- `transmitter.ts` (arah browser→Discord, bukan recording)
- `player.ts`, `mediaSource.ts`, `screenShareAudio.ts` (hook SSRC sudah benar)
- `muxer.ts` (konsolidasi akhir tetap jalan)
- Flake/deps/bundle
## Verification
1. `cd services/discord-gateway && pnpm typecheck` (tsc --noEmit) — 0 error.
2. `pnpm lint` (biome check src/) — exit 0.
3. `pnpm build` (tsc → dist/).
4. Unit test baru (vitest, kalau infra tes ada):
- subscribe terjadi tanpa await metadata (spy urutan panggilan)
- double-start skip lewat guard sinkron
- "stop" → watchdog finalize segmen walau stream tidak "end"
- MIN_DURATION 300ms menyimpan kata pendek
5. Smoke/CI: `nix flake check` (eval). Push → CI `Build & Deploy (Nix)` hijau →
deploy landing (`systemctl show gmw-discord-gateway.service --property=ActiveEnterTimestamp`).
6. Runtime manual (user): join voice, bicara dengan jeda >3s, pastikan 1 alur
kontinu = 1 segmen utuh (bukan 3), dan awal kata tidak hilang.
## Risiko / Trade-off
- Subscribe-before-metadata: burst bot akan dikumpulkan sesaat lalu dibuang
(cost kecil: buang segmen). Lebih baik miss bot daripada miss user.
- AfterSilence naik: file lebih panjang sedikit saat jeda; upload/transkrip
timeout (transcodeToMp3 30s) tetap aman.
- Satu segmen per burst: tidak ada rotasi paksa → durasi segmen = durasi bicara
(bisa menit). Transkrip & transcode tetap ok. Watchdog batasi menggantung.
@@ -1,177 +0,0 @@
# Spec: Receive Others' Screen-Share/Camera Video Under DAVE — Build a DAVE-capable Stream-Watch Connection
Status: **P1–P3 DONE + 4th CRITICAL FIX deployed (f1a7b0c2); DAVE Ready + MLS handshake CONFIRMED live; P4 = waiting on active streamer to confirm video-burst→mp4**
Date: 2026-08-31
Author: Hermes
Related: `docs/specs/2026-08-31_video-receive-eager-selfbot-connection-spec.md` (superseded by this)
`docs/specs/2026-08-31_video-receive-phaseC-spec.md` (Phase C build, selfbot path — dead)
## Problem / Ground truth (established from live logs 2026-08-31)
GMW must record OTHER members' screen-share + camera video in a voice channel it
records. Audio works (via `@discordjs/voice` 0.19.2 negotiating DAVE). Video does
not. Verified live: the selfbot path (`discord.js-selfbot-v13` eager `joinChannel`
→ `joinStreamConnection` → `receiver.createVideoStream`) authenticates but Discord
closes the connection with WS code **4017 "E2EE/DAVE protocol required"** (5x →
`VOICE_CONNECTION_ATTEMPTS_EXCEEDED`). Root cause: **Discord now REQUIRES DAVE
(E2EE) on every voice RTC, and `discord.js-selfbot-v13`'s voice stack predates
DAVE** (identify has no `max_dave_protocol_version`, no MLS handshake). The selfbot
path is dead, cannot be repaired. Full details: skill `gmw-ops` →
`references/video-receive-and-unmute.md` §4.
Facts:
- Watching a stream = a **SEPARATE RTC connection**, not the guild audio socket:
gateway `STREAM_WATCH` (op 20) → Discord replies `STREAM_CREATE` (rtc_server_id)
+ `STREAM_SERVER_UPDATE` (separate token+endpoint) → client opens its own voice
WS+UDP to that endpoint (`StreamConnectionReadonly` in selfbot). The watched
video never rides the @discordjs/voice guild socket.
- The stream-watch RTC ALSO requires DAVE (same 4017 mechanism).
- `@snazzah/davey` (bundled with @discordjs/voice 0.19.2) supports
`MediaType.VIDEO` + `Codec.H264` decrypt — DAVE machinery CAN decrypt H264 video.
- No off-the-shelf DAVE-capable video-RECEIVE path exists. Closing references:
- **Discord-RE/Discord-video-stream** (fork of `@dank074/discord-video-stream`,
master 2026-08-28): full DAVE in `src/client/voice/BaseMediaConnection.ts`
(Davey `DAVESession` init via `initDave`, MLS key-package / proposals /
commit / welcome / transitions; `WebRtcConnWrapper` encrypts audio/video via
`daveSession.encrypt(MediaType.VIDEO, codec, …)`). BUT it is STREAMING only
(send). No STREAM_WATCH / receive.
- `@discordjs/voice`: full DAVE receive for AUDIO only; `DAVESession.decrypt`
hardcodes `MediaType.AUDIO` (dist ~line 892); `onUdpMessage` drops non-opus;
no STREAM_WATCH.
- `discord.js-selfbot-v13`: video receive but no DAVE.
## Goal
Replace the dead selfbot receive path with a **DAVE-capable stream-watch voice
connection**: on detecting a member `voiceState.streaming`, send `STREAM_WATCH`,
connect a DAVE-authenticated RTC to the stream endpoint, decrypt incoming H264
RTP (`MediaType.VIDEO`), reassemble via the existing `H264Depacketizer`, mux to a
playable container. Reuse every tested building block already in the repo.
## Strategy decision (default A; B as fallback) — de-risk in Phase 2
Two implementation routes. Decide by Phase 2 prototype result.
### Strategy A — extend @discordjs/voice's tested native stack (PREFERRED, lighter)
Reuse @discordjs/voice 0.19.2 internals (already a runtime dep, already DAVE-tested
for audio):
- Drive a connection to the stream endpoint using djs/voice's `VoiceWebSocket` +
`VoiceUDPSocket` + `DAVESession` (the same classes that work for the guild
connection — they take arbitrary endpoint/token/session).
- Send the voice identify with `max_dave_protocol_version`, complete the DAVE
handshake (Davey), then on receipt of a video RTP packet call
`daveSession.decrypt(userId, MediaType.VIDEO, packet)` (Davey exposes
`MediaType.VIDEO` + `Codec.H264`) — djs/voice's hardcoded `AUDIO` is the only
blocker, fix by invoking Davey directly with `MediaType.VIDEO` for video SSRCs.
- STREAM_WATCH sent via the existing selfbot `client.ws.broadcast` (cheap, works —
it needs no selfbot voice connection).
- Feed decrypted H264 → `H264Depacketizer` → `.h264` → `muxToMp4` (both already
in `videoReceiver.ts`, unit-tested).
- No new runtime deps. Risk: relies on non-exported djs/voice internals (reachable
via `as any`, as the existing `parsePacket` usage shows).
### Strategy B — port Discord-RE's BaseMediaConnection (heavier, more self-contained)
Port `BaseMediaConnection.ts` DAVE handling + `WebRtcConnWrapper` into a
receive/watch connection in the gateway. Deps: requires `@lng2004/node-datachannel`
(new native WebRTC dep) + `@snazzah/davey` (already available). More code, more
risk (native dep in Nix store), but a clean-room receive path decoupled from
djs/voice internals. Use only if A proves infeasible.
## Files touched (Strategy A shape)
- `services/discord-gateway/src/modules/voice-recording/streamWatchReceiver.ts`
(NEW): DAVE stream-watch connection wrapper. Owns, per watched user:
- `sendStreamWatch(client, streamKey)` (via `client.ws.broadcast({op:20,
d:{stream_key}})`), stream_key = `guild:<gid>:<chid>:<uid>`.
- collects STREAM_CREATE (rtc_server_id) + STREAM_SERVER_UPDATE (token+
endpoint) via `client.on('raw')` match on stream_key.
- builds a djs/voice-style connection to `<endpoint>` with the received token/
session; completes DAVE handshake.
- `onUdpMessage` wrapper: for video payload types, decrypt with
`daveSession.decrypt(userId, MediaType.VIDEO, buf)`, depacketize, write.
- teardown on STREAM_DELETE / user stops streaming / leave / channel untrack.
- `recorder.ts`: wire `trackChannel`/`untrackChannel` already exist; ensure the
selfbot *eager voice connection* attempt is REMOVED (it only 4017-spams logs) —
but KEEP `client.ws.broadcast` availability for STREAM_WATCH.
- `videoRecorder.ts`: remove the dead selfbot `joinChannel`/`joinStreamConnection`
calls; keep the `voiceStateUpdate` streaming detection + teardown bookkeeping as
the entry point; delegate the actual receive to `streamWatchReceiver`.
- `videoReceiver.ts`: keep `H264Depacketizer` + `muxToMp4` (reused). The
guild-socket `hookVideoReceiver` can be removed or left inert.
- Tests: `tests/streamWatchReceiver.test.ts` (DAVE-handshake stub, RTP decrypt path
with a mocked Davey, STREAM_WATCH packet shape); keep `tests/videoReceiver.test.ts`.
## Phases (each independently verifiable)
1. **Phase 1 (this session): spec + source reconnaissance.** Confirm djs/voice
internals are reachable (VoiceWebSocket/VoiceUDPSocket/DAVESession exports &
shapes), confirm Davey `MediaType.VIDEO` decrypt signature, confirm how a raw
stream-watch connection's identify/select-protocol flows. Verify the selfbot
`streamKey` format + `raw` STREAM_CREATE/SERVER_UPDATE payload. GATE: accurate
spec + no unknowns blocking A.
2. **Phase 2: de-risk prototype.** Standalone script (not in the gateway) that:
logs into the same selfbot token, joins a real voice channel, sends STREAM_WATCH
for a live streamer, receives STREAM_CREATE/SERVER_UPDATE, and attempts a
DAVE-authenticated connect + receive of ≥1 H264 packet to prove the path before
any gateway integration. GATE: at least one decrypted H264 NAL captured in the
lab.
3. **Phase 3: gateway integration** per files-touched. GATE: typecheck + build +
biome + unit tests green; CI deploy ok.
4. **Phase 4: live verify.** With a real streamer in a recorded channel: journal
shows `STREAM_WATCH sent`, `DAVE ready`, `Video burst opened`, and a playable
`.h264`/`.mp4`/`.mkv` on disk. GATE: playable file with real video content.
## Risks / open questions
- Does Discord require the stream-watch connection to use the SAME session_id as
the bot's active voice session, or a fresh one? (Affects identify.) Resolve in P2.
- Which video codec does Discord actually send for camera vs GoLive (H264 likely,
but VP9/AV1 possible) — the depacketizer only handles H264. P2 measures the
payload type live; add depacketizers for other codecs only if observed.
- djs/voice `DAVESession`/`VoiceUDPSocket` reachability via `as any` must be
confirmed against the installed 0.19.2 build (P1).
- The separate stream RTC may need `selectProtocol`/SDP even for receive-only; the
Discord-RE SDP shows a `m=video ... inactive` section. Follow the same shape.
## Verification (overall)
- Per-phase gates above.
- No regression: audio recording + message capture still work after changes.
- `pnpm typecheck && pnpm build && pnpm lint` green in discord-gateway.
- Commit + push; CI `Build & Deploy (Nix)` green; live streamer produces a file.
## Phase 1 findings (CONFIRMED 2026-08-31, Strategy A feasible)
- `@discordjs/voice` 0.19.2 dist/index.mjs PUBLICLY exports exactly the primitives
needed: `DAVESession`, `Networking`, `NetworkingStatusCode`, `VoiceConnection`,
`VoiceReceiver`, `VoiceUDPSocket`, `VoiceWebSocket`, `SSRCMap`,
`RTP_OPUS_PAYLOAD_TYPE` (export block ~3143). So a stream-watch connection can be
built OUTSIDE the lib using these constructors — no `as any` needed for the heavy
lifting.
- `Networking` child wiring (~line 1364-1484): `new VoiceWebSocket('wss://' +
endpoint + '?v=8', debug)`; on WS open send Identify `{op, d:{server_id,
user_id, session_id, token, max_dave_protocol_version: getMaxProtocolVersion()}}`;
`createDaveSession(protocolVersion)` → `new DAVESession(protocolVersion, userId,
channelId, {decryptionFailureTolerance})` then `.reinit()`; UDP via
`new VoiceUDPSocket({ip, port})` after Ready gives modes + ssrc.
- `DAVESession` wraps `@snazzah/davey` `Davey.DAVESession(protocolVersion, userId,
channelId)`; on network packets it calls `this.session.decrypt(userId,
Davey.MediaType.AUDIO, packet)` — hardcoded AUDIO (line ~892). For video we call
Davey directly with `MediaType.VIDEO` + `Codec.H264`.
- `@snazzah/davey` MediaType enum: AUDIO=0, VIDEO=1; Codec H264=4; methods
`decrypt(mediaType, codec, packet): Buffer` + `encrypt(...)`. Confirmed in davey
index.d.ts.
- Stream key format (selfbot VoiceConnection.js ~1240): `guild:<gid>:<chid>:<uid>`
for guild channels; `STREAM_WATCH` = gateway op 20, `d:{stream_key}`;
`sendSignalScreenshare` = `client.ws.broadcast({op:20,d:{stream_key}})`. Replies
come as gateway `raw` events `STREAM_CREATE` (`d.rtc_server_id`) +
`STREAM_SERVER_UPDATE` (`d.token`, `d.endpoint`); selfbot routes them to the
stream connection via `client.on('raw')` matching `d.stream_key`, setting
`setSessionId(sessionId)` + `setTokenAndEndpoint(token, endpoint)` (Watch case in
`StreamConnectionReadonly`).
- Discord-RE reference for the identify SDP: stream connections send a `m=video`
section with `a=inactive` (receive-only-ish) + standard DAVE/VoiceOpCodes
(op 0 identify, op 2 select protocol incl. `max_dave_protocol_version`). See
`BaseMediaConnection.handleProtocolAck` + `initDave`.
- Decision: proceed with **Strategy A**. Selfbot code to REMOVE: the eager
`ensureSelfbotVoice` join + `joinStreamConnection`/`receiver.createVideoStream`
in `videoRecorder.ts` (proven dead — 4017). Keep the `voiceStateUpdate` streaming
detection + bookkeeping; swap the receive plumbing to a new `streamWatchReceiver`
driven by a djs/voice-style connection. STREAM_WATCH itself still sent via the
selfbot `client.ws.broadcast` (needs only the WS, not a selfbot voice conn).
- OPEN (resolve in Phase 2 lab): (a) whether the stream connection's identify must
use the bot's ACTIVE voice session_id or a fresh one; (b) actual video codec Discord
sends for camera vs GoLive (measure payload type live; H264 assumed, VP9/AV1 possible
→ add depacketizers only if observed).
@@ -1,93 +0,0 @@
# Spec: Fix Video Capture — Eagerly Establish the Selfbot Voice Connection at Join Time
Status: PLANNED
Date: 2026-08-31
Author: Hermes
Related: `docs/specs/2026-08-31_video-receive-phaseC-spec.md` (Phase C build, made Option A this fix)
## Symptom (from live logs, 2026-08-31 ~12:34)
A user was actively screen-sharing + on camera in the recorded voice channel.
The gateway recorded MANY users' audio (.ogg) fine, but video capture produced
nothing. The only video signal in `journalctl -u gmw-discord-gateway` was:
```
[VOICE (guild:2)]: Sending voice state update: {"self_mute":false,...,"flags":2}
[VOICE] received voice state update: {member hunterz ...} # OTHER user, not bot
[VOICE] connection? true, guild session channel
[VOICE (guild:2)]: Setting sessionId <S> (stored as "undefined")
[VOICE (guild:2)]: Authenticated with sessionId <S> # debug print only
[VOICE (guild:2)]: Authenticate failed - VOICE_CONNECTION_TIMEOUT # +15s
video-recorder: userId=..., "Connection not established within 15 seconds."
```
## Root cause (verified against discord.js-selfbot-v13 3.7.1 source)
The gateway records audio via `@discordjs/voice` (`joinVoiceChannel` + adapter).
Video receive lives on the SEPARATE selfbot `ClientVoiceManager.connection`
(a singleton `VoiceConnection`). `videoRecorder.ts` currently calls
`client.voice.joinChannel(channel)` LAZILY — only when a `voiceStateUpdate`
shows `newState.streaming === true`.
At that moment the bot is ALREADY connected to the channel via @discordjs/voice.
A selfbot `joinChannel` then does `VoiceConnection.authenticate()` →
`sendVoiceStateUpdate()`, and waits for a fresh `VOICE_SERVER_UPDATE`
(`setTokenAndEndpoint`) + `VOICE_STATE_UPDATE` (`setSessionId`) to reach
`checkAuthenticated()` (needs token+endpoint+sessionId). Because the bot is
already in an established voice session, Discord does NOT emit a new
`VOICE_SERVER_UPDATE` for the lazy selfbot re-join → token/endpoint never set →
15s `VOICE_CONNECTION_TIMEOUT`.
This is fatal to video: `joinStreamConnection(userId)` (STREAM_WATCH op 20) and
`receiver.createVideoStream(userId, out)` (Recorder/ffmpeg) BOTH live on the
parent selfbot `VoiceConnection` and require it `CONNECTED` (its own voice
WS+UDP socket feeds `PacketHandler.push`, authenticated with
`authentication.secret_key`).
## Fix — establish the selfbot connection eagerly, at voice-join time
The selfbot `VoiceConnection` must exist and be `CONNECTED` before any streamer
appears. Establish it once, synchronously alongside the @discordjs/voice join in
`recorder.startRecording`, so it rides the bot's FRESH voice join — when Discord
DOES emit VOICE_SERVER_UPDATE. Then cache it and let `videoRecorder` reuse it.
Ordering: in `startRecording`, after the @discordjs/voice `joinVoiceChannel`
returns (and retries) — fire `ensureSelfbotVoice(channel)` best-effort:
1. `await client.voice.joinChannel(channel, { selfMute:false, selfDeaf:false,
selfVideo:false })` (rejects ~VOICE_CONNECTION_TIMEOUT on failure → log +
return null; do NOT block audio).
2. Cache the returned selfbot `VoiceConnection` keyed by guildId.
3. Wire teardown: on `recorder` voice stop / destroyed → `untrackChannel` +
destroy the cached selfbot connection (`disconnect()`).
`videoRecorder.startVideoRecording` then uses the cached selfbot connection:
- If cached & `status === CONNECTED` → use it.
- Else → fall back to a lazy `joinChannel` (still best-effort).
## The two-connection coexistence risk (must verify live)
@discordjs/voice (audio) and the selfbot `VoiceConnection` (video) each open
their OWN low-level voice WS+UDP on the same session. The spec's original
open-question flagged this. Mitigations:
- Clear logging: `Selfbot voice connected (guild=...)`, plus a periodic
`djs/voice status` log so we can confirm audio stays `READY` while the selfbot
connection is up.
- If Discord kicks/breaks the audio connection, logs will show
@discordjs/voice `Disconnected`/reconnect churn — we detect and pivot.
## Files touched
- `services/discord-gateway/src/modules/voice-recording/videoRecorder.ts`:
add `ensureSelfbotVoice(channel)` (return cached/connected), use it in
`startVideoRecording`, add `destroyGuildSelfbotVoice(guildId)`,
richer status logging.
- `services/discord-gateway/src/modules/voice-recording/recorder.ts`: call
`ensureSelfbotVoice(channel)` after `joinVoiceChannel` (best-effort);
call `destroyGuildSelfbotVoice` on voice stop/destroy.
- Tests: `tests/videoRecorder.test.ts` (update to assert eager-connection reuse
+ status gating).
## Verification
1. `pnpm typecheck` + `pnpm build` + biome clean (discord-gateway).
2. Tests green.
3. Commit + push; CI `Build & Deploy (Nix)` green, service restarts.
4. LIVE (deploy): join a channel with the bot → journal shows
`Selfbot voice connected` (parent CONNECTED). When a member streams →
`Sender signal screenshare` / `Video recorder ready` + a `.mkv` under
`<RECORDINGS_DIR>/<uid>/video-*.mkv`; playable via ffmpeg. Confirm audio
recording still flows (no djs/voice reconnect churn).
@@ -1,97 +0,0 @@
# Spec: Record Other Users' Video (Camera / Screen Share) — Phase C
Status: PLANNED (not built)
Date: 2026-08-31
Author: Hermes
Related: `docs/specs/2026-08-30_video-record-receive-spec.md` (Phase A/B — raw UDP hook, superseded for receive)
## TL;DR — what changed vs Phase A/B
Phase A/B (commit `999c054b` etc.) hooked `@discordjs/voice`'s UDP socket to capture non-opus RTP and
depacketize H264 → mp4. **It captured ZERO video** because `@discordjs/voice` never authorizes the bot to
receive others' video (no STREAM_WATCH). This spec replaces that approach with the **native, selfbot-lib
receive path**, which is battle-tested and does the authorization + decryption + ffmpeg muxing for us.
## Ground truth (verified in discord.js-selfbot-v13 3.7.1 source)
1. `ClientVoiceManager.joinChannel(channel, config)` → a **selfbot `VoiceConnection`** with
`.receiver` (`VoiceReceiver` → `PacketHandler`). [ClientVoiceManager.js:102-118]
2. `VoiceConnection.receiver` is created in the constructor. [VoiceConnection.js:140]
3. `VoiceReceiver.createVideoStream(user, output)` → `PacketHandler.makeVideoStream` → **`Recorder`**
(ffmpeg that muxes H264+Opus RTP over UDP → **Matroska (.mkv)**). [Receiver.js, Recorder.js]
4. `PacketHandler` routes: video RTP → Recorder UDP 65506, opus RTP → UDP 65510; decodes all via
`connection.authentication.{secret_key, mode}` (supports `aead_aes256_gcm_rtpsize` and
`aead_xchacha20_poly1305_rtpsize` = DAVE-compatible). [PacketHandler.js:115-155, 195-240]
5. `StreamConnectionReadonly.joinStreamConnection(userId)` + `sendSignalScreenshare()` sends
gateway op `STREAM_WATCH` so Discord actually forwards the streamer's RTP to us. [VoiceConnection.js:1100-1240]
6. `VoiceState.streaming` = `data.self_stream ?? false` — lets us detect a streamer on voice state update. [VoiceState.js:94]
## Problem / the crux
The gateway's voice today is **`@discordjs/voice`** (audio + music + GoLive-send). The selfbot-lib
video-receive path lives on the **selfbot-lib `VoiceConnection`** — a separate voice stack. Two options:
### Option A (RECOMMENDED): Parallel selfbot video-watch connection
Keep `@discordjs/voice` for everything it does today. Add a **second, selfbot-lib voice connection**
to the same channel whose ONLY job is to watch + record others' video.
- Pros: zero regression risk to audio/music/screenshare-send; uses native `createVideoStream` → mk4.
- Cons: two voice connections for the same bot user in one channel. Need to verify Discord tolerates it
(real selfbots like Discord-RE do exactly this for multi-stream). The selfbot lib's `joinChannel`
reuses `ClientVoiceManager.connection` (it's a singleton) — see caveat below.
### Option B: Migrate primary voice to selfbot lib
Make the selfbot `VoiceConnection` THE voice layer (it also does audio via `receiver.createStream`).
- Pros: one connection; video+audio unified.
- Cons: large refactor; high regression risk to the entire existing audio/music/GoLive stack. NOT chosen now.
## CAVEAT — ClientVoiceManager.connection is a singleton
`ClientVoiceManager.connection` is a single `VoiceConnection`. The gateway's `@discordjs/voice` adapter and
the selfbot lib both drive the same client voice state. Need to verify whether `client.voice.joinChannel()`
can coexist with the active `@discordjs/voice` session, or whether we must create the selfbot VoiceConnection
manually / re-use the existing voice state. This is the #1 technical risk to validate in the spike before
committing to Option A.
## Implementation plan (Option A)
### 1. Streamer detector (new: `modules/voice-recording/videoRecorder.ts`)
- Listen to voice state updates (`client.on('voiceStateUpdate')` or the existing voice-state hook).
- When `voiceState.streaming === true` for a member in the bot's channel → candidate to record.
- Skip bot's own user id (unless we also want self-video; default skip).
### 2. Watch + record wiring
- Ensure a selfbot-lib `VoiceConnection` exists for the channel (spike: `client.voice.joinChannel(channel)`,
fallback: build a `VoiceConnection` directly from the existing voice auth).
- `await selfbotVoiceConn.joinStreamConnection(userId)` → STREAM_WATCH op 20.
- `const recorder = selfbotVoiceConn.receiver.createVideoStream(userId, outPath)` where outPath points under
`<RECORDINGS_DIR>/<uid>/video-<streamKey>-<ts>.mkv` (Recorder outputs MKV natively).
- On `recorder.on('ready')` → mark recording; `recorder.on('closed')` → finalize.
- Transcript later: MKV → mp4 via ffmpeg (Phase B `muxToMp4` can accept mkv) for dashboard playback.
### 3. Teardown
- When `voiceState.streaming === false` / user leaves / channel emptied → `recorder.destroy()`,
`selfbotVoiceConn.streamWatchConnection.delete(userId)` / `sendStopScreenshare()`.
### 4. Frontend (Phase UI, later)
- oRPC/backend list `.mkv` per call session + FE `<video>` player (mirror audio recordings UI).
## Files touched
- `services/discord-gateway/src/modules/voice-recording/videoRecorder.ts` (new)
- `services/discord-gateway/src/modules/voice-recording/recorder.ts` (wire streamer detector on voice join)
- Possibly `voiceController.ts` (voice state update subscription)
- Tests: `tests/videoRecorder.test.ts` (mock selfbot VoiceConnection + Recorder)
## Verification
1. `pnpm typecheck` + `pnpm build` + biome clean in discord-gateway.
2. Unit: Recorder wiring + streamer detection with mocked VoiceConnection.
3. Live (deploy): user shares screen → journal shows `STREAM_WATCH` sent + `Recorder ready` + `.mkv` file
appears under recordings dir; playable via ffmpeg.
4. CI Build & Deploy (Nix) green.
## Open questions for spike (before full build)
- [ ] Can `client.voice.joinChannel()` run alongside the active `@discordjs/voice` session, or does the
singleton `ClientVoiceManager.connection` collide / tear down the existing audio connection?
- [ ] Does the selfbot `VoiceConnection` need the bot's `video: true` flag in IDENTIFY to receive video
(it advertises `streams` in IDENTIFY — see BaseMediaConnection/identify vs selfbot VoiceConnection)?
- [ ] Does `Recorder` (spawns system ffmpeg, UDP loopback on 65506/65510) work in the Nix store runtime
(ffmpeg-headless on PATH confirmed; UDP loopback fine)?
@@ -1,33 +0,0 @@
# Video Recording Splitting — Like Voice Recording
## Goal
Camera + screen share (stream watch) recording should split into per-burst
segments just like voice recording does — each time a streamer pauses/stops
and resumes, a new MP4 segment is created and registered in the DB + uploaded.
## Voice Recording Model (to replicate)
1. `receiver.speaking.start` → new OGG segment per burst
2. AfterSilence (4000ms) → stream "end" → segment finalized + uploaded
3. Each segment → DB insert → OGG→MP3 transcode → upload → update DB
4. File stored as `<userId>/<startTime>.ogg` + `.json`
## Video Recording Splitting
1. DAVE video RTP → depacketize H264 → write to current segment .h264
2. Silence detection: no H264 packets for 4000ms → close segment → flush →
mux to MP4 → insert DB record → upload → start new segment on next packet
3. Each segment: `<userId>/video-<channelId>-<startTime>.h264` → `.mp4`
4. DB: reuse `voice_recordings` table (filename indicates video, e.g. `video-XXX-1234.mp4`)
5. Upload: MP4 to TeleUploader (no transcode needed — MP4 plays everywhere)
## Files Modified
- `services/discord-gateway/src/modules/voice-recording/streamWatchReceiver.ts`
— Main change: silence-based splitting + DB registration + upload
## Constants
- `VIDEO_SILENCE_MS = 4000` (matches voice AfterSilence)
- `VIDEO_MIN_SEGMENT_MS = 1000` (skip segments <1s — avoid noise)
## Verification
- `pnpm typecheck` in `services/discord-gateway`
- `pnpm build` (dist/ is the deployed artifact)
- Push → CI deploy → live test with a streamer
@@ -1,81 +0,0 @@
# Spec: Selfbot-Viable Video Capture — manual screen-share watch command (Phase D)
Status: PLANNED (not yet built)
Date: 2026-09-02
Author: Hermes
Related: `docs/specs/2026-08-31_video-receive-phaseC-spec.md` (auto-receive, superseded
for selfbot), `gmw-ops/references/selfbot-presence-detection-limits.md`,
`gmw-ops/references/discord-voice-fork-video-receive.md`
## TL;DR — the decisive finding (verified live 2026-09-02)
User insists on keeping the **selfbot** (no bot-token migration). Live diagnostics prove
a selfbot CANNOT auto-detect other members' camera/share because:
- It never receives `VOICE_STATE_UPDATE` for other members (only its own).
- `guild.members.fetch()` → 403, `GET /channels/{id}/voice-states` → 404.
- No `GUILD_CREATE`, no `READY.broadcaster_user_ids` presence.
- `scanExistingStreamers` + `handleVoiceStateUpdate` (the only two `startStreamWatch`
triggers) are therefore both **dead on a selfbot**.
- No manual watch command exists today, so even on-demand capture is impossible.
→ The ONE selfbot-viable path is a **manual, operator-initiated STREAM_WATCH** on a
member known to be screen-sharing. Gateway op 20 (STREAM_WATCH) is **NOT gated on
bot-vs-user**; the DAVE handshake to Ready+MLS was already verified live in earlier
sessions. The receive/mux/segment/upload pipeline (`streamWatchReceiver.ts`) is already
built and only lacks a real streamer to produce its first `.mp4`.
Camera-of-others is NOT viable on a selfbot even with `unknown-ssrc` fallback:
`@discordjs/voice` `parsePacket` calls `daveSession.decrypt(packet, userId)` keyed per
REAL userId (vendor fork dist/index.js:2143), so a fake id selects no MLS decryptor →
garbage, not H264. (The uncommitted `unknown-ssrc` change was reverted this session.)
Selfbot CAN capture the OWNER's own video (its own VOICE_STATE_UPDATE + fork op12
videoSSRC are attributable), but `videoRecorder.ts` hard-skips its own id — parameterized
self-capture is a follow-up, not the default.
## Goal
Add a **manual watch command** so an operator can say "record <member>'s screen share"
and the gateway `startStreamWatch`s that member → DAVE watch → per-burst `.mp4` segments
(mirroring voice silence split) → upload → DB `voice_recordings` → dashboard `<video>`.
This is the only form of OTHER-member video capture a selfbot can deliver, and it is
genuinely buildable with the existing receive pipeline.
## Scope / files
Gateway (`services/discord-gateway`):
- New command type `VIDEO_WATCH` + handler in `command-handler/` (dedicated
`video.handler.ts`), routed via `createHandlerRegistry`.
- Handler resolves a VoiceChannel (from persisted `voice_auto_reconnect` / active
connections) + target memberId from the command payload, calls
`startStreamWatch(channel, memberId)` (already exported).
- Idempotent (startStreamWatch early-returns if a watch exists); a `VIDEO_UNWATCH`
command calls `stopStreamWatch(guildId, userId)`.
- Reply: success/failure via the standard `CommandReply` publish.
Backend (`services/backend`):
- oRPC procedure (or the existing command bridge) that publishes a `VIDEO_WATCH`
command to `backend:command` with `{ guildId, channelId, userId }`. Reuse the same
bridge the FE already uses for voice commands.
Frontend (`services/frontend`):
- A "Video Watch" control: pick a voice member + a "Record screen" button → calls the
backend procedure. Shows live status (watching / recording / segments uploaded).
(Each layer optional independently; gateway alone gives a Redis-testable path.)
## Verification
1. `pnpm typecheck` + `pnpm build` + `biome check src/` green in discord-gateway.
2. Unit test: handler publishes reply + calls startStreamWatch with the right args
(mock the module).
3. Live: operator invokes `!videorec <member>` while that member screen-shares →
journal shows `Sending STREAM_WATCH` → `STREAM_CREATE` → `DAVE watch READY` → `Video
burst opened` → `Video muxed to mp4` → a `video-*.mp4` appears under
`<recordingsDir>/<uid>/` and a `video-%` row lands in `voice_recordings`.
4. `Build & Deploy (Nix)` CI green.
## Out of scope (documented dead ends on selfbot)
- Auto camera/share capture of OTHER members (impossible at detection layer).
- Camera-of-others via `unknown-ssrc` (DAVE decrypt needs real userId).
- Bot-token migration (user declined).
-3
View File
@@ -1,3 +0,0 @@
*
!.gitkeep
!README.md
View File
-15
View File
@@ -1,15 +0,0 @@
# Recordings Directory
This directory is mounted as a volume in the discord-gateway container.
Voice recordings are stored here with the following structure:
```
recordings/
├── <user-id>/
│ ├── <timestamp>.ogg
│ └── <timestamp>.json
└── sessions/
└── <session-id>.json
```
The directory must be writable by UID 1000 (app user in the container).
-3
View File
@@ -59,8 +59,6 @@ src/
| messages | Store & query Discord messages | `messages`, `ai_moderations`, `ai_moderation_flags` |
| moderation | Moderation actions & metrics | `ai_moderations`, `moderation_actions` |
| media | Media file management | `media_attachments` |
| voice | Live speakers + recordings | `voice_recordings` |
| recordings | Recordings API | `voice_recordings` |
| dashboard | Stats aggregation | Various (read-only) |
| knowledge | Semantic search | Qdrant vector DB |
| chatbot | AI chatbot with tools | `chatbot_history` |
@@ -91,7 +89,6 @@ The frontend fetches via `src/lib/api/server.ts` (SSR, server-side) and
```
discord:message:{created,updated,deleted,analyzed}
discord:attachment:{created,uploaded}
discord:voice:{started,stopped,uploaded,active_user,pcm,analyzed}
discord:analysis:queue_status
discord:reaction:{added,removed}
discord:thread:{created,deleted,updated}
-12
View File
@@ -35,16 +35,6 @@ services/backend/
│ │ │ └── routes/
│ │ │ └── index.ts
│ │ │
│ │ ├── media/
│ │ │ ├── media.service.ts
│ │ │ └── routes/
│ │ │ └── index.ts
│ │ │
│ │ ├── voice/
│ │ │ ├── voice.service.ts
│ │ │ └── routes/
│ │ │ └── index.ts
│ │ │
│ │ └── health/
│ │ ├── health.schema.ts
│ │ ├── health.repository.ts
@@ -148,8 +138,6 @@ export const messageQuerySchema = z.object({
|--------|---------|--------|
| **messages** | Text message storage & retrieval | GET /api/messages, GET /api/messages/:channelId |
| **analytics** | Moderation statistics & trends | GET /api/analytics/overview, /daily-trend, /hourly-stats |
| **media** | Media file management | GET /api/media/list, POST /api/media/upload |
| **voice** | Voice recording management | GET /api/voice/recordings, POST /api/voice/connect |
| **health** | Service health checks | GET /api/health |
## Data Flow
-1
View File
@@ -20,7 +20,6 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@discordjs/voice": "^0.19.2",
"@orpc/server": "1.15.2",
"axios": "^1.20.0",
"dotenv": "^18.0.1",
+2 -208
View File
@@ -8,9 +8,6 @@ importers:
.:
dependencies:
'@discordjs/voice':
specifier: ^0.19.2
version: 0.19.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
'@orpc/server':
specifier: 1.15.2
version: 1.15.2(@opentelemetry/api@1.9.1)(ws@8.21.3)
@@ -132,10 +129,6 @@ packages:
cpu: [x64]
os: [win32]
'@discordjs/voice@0.19.2':
resolution: {integrity: sha512-3yJ255e4ag3wfZu/DSxeOZK1UtnqNxnspmLaQetGT0pDkThNZoHs+Zg6dgZZ19JEVomXygvfHn9lNpICZuYtEA==}
engines: {node: '>=22.12.0'}
'@emnapi/core@2.0.0-alpha.3':
resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==}
@@ -479,97 +472,6 @@ packages:
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@snazzah/davey-android-arm-eabi@0.1.12':
resolution: {integrity: sha512-6VC/an+Sx5dI5skb+90rYcIB1jhm48Rl0nDaw0UNT4bz1rMjpVfmmZqeocYXMq96IdbBMlE6OTKGcBm2C3gkQg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
'@snazzah/davey-android-arm64@0.1.12':
resolution: {integrity: sha512-0Bwd03/JsTFhlPhF4q/LW0RxSzntFpQdhz+TBdFljYSg8IEyA38saPJeTNjpIgDfhAumPzvhCdfS6O5qT8yXDw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@snazzah/davey-darwin-arm64@0.1.12':
resolution: {integrity: sha512-lKMV6ITi9BQLt0fx/pAT7M8xcojVK7bryVJGdaW3bq8gABFslS3ti/KzrWabQvhpEV71FZe5mV0UcKHFVaTsZw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@snazzah/davey-darwin-x64@0.1.12':
resolution: {integrity: sha512-vXXc/eW/e3TQeb7VsdtrPqs3/22j0aSqiP1ZXmZtDjQRBwgSxwItWYa6sh5MELP2EHB2igNlGzB6Hc0XlbGi4g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@snazzah/davey-freebsd-x64@0.1.12':
resolution: {integrity: sha512-G1gas5HrC4Xp3mRY0+OeAqXS6fG2tRgBEc8gQh69Hw4YK9RV9mzQKcmoKMkBM72U1+2C+2u57dolnKKzwozByQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@snazzah/davey-linux-arm-gnueabihf@0.1.12':
resolution: {integrity: sha512-97Fujh82r2Ll7dPZeNuoZ3yKfsqycf3c93OWXOo/ThNL/18Onl2Ht4SIvpX6VHhfeS9bDpHJ1lHCaz1b/i5ocw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@snazzah/davey-linux-arm64-gnu@0.1.12':
resolution: {integrity: sha512-FWyAOv52cHKDM4BOsmImKKogHFvqNFoXmZcicNJbX3XpVl8Mas88ZoXQ+IA5V+qc9pNtAl1MbWwEZ9JrqAQtbg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@snazzah/davey-linux-arm64-musl@0.1.12':
resolution: {integrity: sha512-ptRbLSQxtV6EjXppS5z7qaPDI0NRKhrkJYsTlAjEghmOvlAObozSCYYnMO6nbkt6Ab3+lWqyahClzcRNcD2ouw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@snazzah/davey-linux-x64-gnu@0.1.12':
resolution: {integrity: sha512-w86fZvhJn0ErOoAQHt2UbQ95V/cgwvfvQ4GlTPQLCzt58nn+rLlXLgPn90qYlSQrZxFW38rKXwqVOMbm9p+pwQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@snazzah/davey-linux-x64-musl@0.1.12':
resolution: {integrity: sha512-LLNnO+hfG41ymeI+O1YHo5/0h3aKaetUNLdkBwpdJsjoyKMXZaeCnB+aHNkkupJCMPmWS0g6iPMCHUOqZSBcTg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@snazzah/davey-wasm32-wasi@0.1.12':
resolution: {integrity: sha512-MPKFuqYVkDFXheR7qmtEY4FWxQ/ADfgsCojQWHi13sibUqCTR9q2F1LqNn2i9IVh3sh1sxeg87fdFMCH63pl7g==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@snazzah/davey-win32-arm64-msvc@0.1.12':
resolution: {integrity: sha512-Uf4OYHyfbXpzyaOqIV8/h6kv166Qni5+Bxmc1E/ov4uhhKO8qXbOky8zbVOzu0U4cv5ll3s4IQ8jDAJkx/K75Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@snazzah/davey-win32-ia32-msvc@0.1.12':
resolution: {integrity: sha512-nRVbKTsb2ldcPI8D4BDA7P/UeiMEMvR+wYuUMp7H1pRD/3dF2hKo+MzUU+Pn78EhuYA3CWHItiAcS/GsPld67A==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@snazzah/davey-win32-x64-msvc@0.1.12':
resolution: {integrity: sha512-AgUA3itPDVkxQq7RIkgE1thCiWePwWjyfOZefBBxGIlMWpRUOSwF4vY9kNLnrScyJcFULdR+Zm8PwiwV8/RKnw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@snazzah/davey@0.1.12':
resolution: {integrity: sha512-V+NlX5931RwVamZhhEfZekMdcvXDKdMAmHW1AuGaykVQsNyBOq3bpmGpoKRBDCYgFWKIufJ0Dcg3m4cYhvUy6g==}
engines: {node: '>= 10'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -857,9 +759,6 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
discord-api-types@0.38.52:
resolution: {integrity: sha512-uwe9EKfbjsmgWc2fdFjvDbj+dQqx3lp7wqDCmIha0jInuU+xeQjkCK9tMMn+p7RXfdVQORCInq4cD3U2ymDmyg==}
dotenv@18.0.1:
resolution: {integrity: sha512-0eR4m4D/jH5eaI3evo2ZqqMii5mrTCR12v12VChdie6O1gPHs7XAjzTep0CWc1Bd+oNx3vVfbi734dK4zghigw==}
engines: {node: '>=12'}
@@ -1323,23 +1222,6 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
prism-media@1.3.5:
resolution: {integrity: sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==}
peerDependencies:
'@discordjs/opus': '>=0.8.0 <1.0.0'
ffmpeg-static: ^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0
node-opus: ^0.3.3
opusscript: ^0.0.8
peerDependenciesMeta:
'@discordjs/opus':
optional: true
ffmpeg-static:
optional: true
node-opus:
optional: true
opusscript:
optional: true
process-warning@5.1.0:
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
@@ -1663,24 +1545,6 @@ snapshots:
'@biomejs/cli-win32-x64@2.5.14':
optional: true
'@discordjs/voice@0.19.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)':
dependencies:
'@snazzah/davey': 0.1.12(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
'@types/ws': 8.18.1
discord-api-types: 0.38.52
prism-media: 1.3.5
tslib: 2.8.1
ws: 8.21.3
transitivePeerDependencies:
- '@discordjs/opus'
- '@emnapi/core'
- '@emnapi/runtime'
- bufferutil
- ffmpeg-static
- node-opus
- opusscript
- utf-8-validate
'@emnapi/core@2.0.0-alpha.3':
dependencies:
'@emnapi/wasi-threads': 2.0.1
@@ -1941,73 +1805,6 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {}
'@snazzah/davey-android-arm-eabi@0.1.12':
optional: true
'@snazzah/davey-android-arm64@0.1.12':
optional: true
'@snazzah/davey-darwin-arm64@0.1.12':
optional: true
'@snazzah/davey-darwin-x64@0.1.12':
optional: true
'@snazzah/davey-freebsd-x64@0.1.12':
optional: true
'@snazzah/davey-linux-arm-gnueabihf@0.1.12':
optional: true
'@snazzah/davey-linux-arm64-gnu@0.1.12':
optional: true
'@snazzah/davey-linux-arm64-musl@0.1.12':
optional: true
'@snazzah/davey-linux-x64-gnu@0.1.12':
optional: true
'@snazzah/davey-linux-x64-musl@0.1.12':
optional: true
'@snazzah/davey-wasm32-wasi@0.1.12(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)':
dependencies:
'@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
'@snazzah/davey-win32-arm64-msvc@0.1.12':
optional: true
'@snazzah/davey-win32-ia32-msvc@0.1.12':
optional: true
'@snazzah/davey-win32-x64-msvc@0.1.12':
optional: true
'@snazzah/davey@0.1.12(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)':
optionalDependencies:
'@snazzah/davey-android-arm-eabi': 0.1.12
'@snazzah/davey-android-arm64': 0.1.12
'@snazzah/davey-darwin-arm64': 0.1.12
'@snazzah/davey-darwin-x64': 0.1.12
'@snazzah/davey-freebsd-x64': 0.1.12
'@snazzah/davey-linux-arm-gnueabihf': 0.1.12
'@snazzah/davey-linux-arm64-gnu': 0.1.12
'@snazzah/davey-linux-arm64-musl': 0.1.12
'@snazzah/davey-linux-x64-gnu': 0.1.12
'@snazzah/davey-linux-x64-musl': 0.1.12
'@snazzah/davey-wasm32-wasi': 0.1.12(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
'@snazzah/davey-win32-arm64-msvc': 0.1.12
'@snazzah/davey-win32-ia32-msvc': 0.1.12
'@snazzah/davey-win32-x64-msvc': 0.1.12
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
'@standard-schema/spec@1.1.0': {}
'@tybys/wasm-util@0.10.3':
@@ -2233,8 +2030,6 @@ snapshots:
detect-libc@2.1.2: {}
discord-api-types@0.38.52: {}
dotenv@18.0.1: {}
drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0):
@@ -2618,8 +2413,6 @@ snapshots:
dependencies:
xtend: 4.0.2
prism-media@1.3.5: {}
process-warning@5.1.0: {}
prom-client@15.1.3:
@@ -2787,7 +2580,8 @@ snapshots:
toidentifier@1.0.1: {}
tslib@2.8.1: {}
tslib@2.8.1:
optional: true
tsx@4.23.15:
dependencies:
+9 -23
View File
@@ -6,7 +6,13 @@ import { describe, expect, it } from "vitest";
const BASE = process.env.API_BASE ?? "http://localhost:4001/api";
async function api(path: string, init?: RequestInit) {
/** Result of a JSON API call: status + parsed body (or null for 204/empty). */
interface ApiResult {
status: number;
body: Record<string, unknown> | null;
}
async function api(path: string, init?: RequestInit): Promise<ApiResult> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
@@ -23,7 +29,8 @@ describe("API Health", () => {
});
it("GET /metrics returns prometheus text", async () => {
const res = await fetch(`${BASE.replace("/api", "")}/api/metrics`);
const base = BASE.endsWith("/api") ? BASE.slice(0, -4) : BASE;
const res = await fetch(`${base}/api/metrics`);
expect(res.status).toBe(200);
const text = await res.text();
expect(text).toContain("nodejs");
@@ -41,20 +48,6 @@ describe("API Dashboard", () => {
});
});
describe("API Recordings", () => {
it("GET /recordings returns items with pagination", async () => {
const { status, body } = await api("/recordings?limit=5");
expect(status).toBe(200);
expect(body).toHaveProperty("items");
expect(Array.isArray(body.items)).toBe(true);
if (body.items.length > 0) {
expect(body.items[0]).toHaveProperty("id");
expect(body.items[0]).toHaveProperty("username");
expect(body.items[0]).toHaveProperty("created_at");
}
});
});
describe("API Config", () => {
it("GET /config returns 200", async () => {
const { status } = await api("/config");
@@ -62,13 +55,6 @@ describe("API Config", () => {
});
});
describe("API Voice", () => {
it("GET /guilds returns 200", async () => {
const { status } = await api("/guilds");
expect(status).toBe(200);
});
});
describe("API Negative", () => {
it("GET /nonexistent returns 404", async () => {
const { status } = await api("/nonexistent");
@@ -1,13 +0,0 @@
import { z } from "zod";
export const mediaQueueSchema = z.object({
source: z.string().min(1, "source is required"),
mode: z.enum(["music", "screen"]).default("music"),
});
export const mediaLoopSchema = z.object({
loop: z.boolean().default(false),
});
export type MediaQueueInput = z.infer<typeof mediaQueueSchema>;
export type MediaLoopInput = z.infer<typeof mediaLoopSchema>;
@@ -1,171 +0,0 @@
import {
createChildLogger,
tryCommandThenFallback,
} from "../../shared/commandHelper.js";
import {
COMMAND_MEDIA_LOOP,
COMMAND_MEDIA_QUEUE,
COMMAND_MEDIA_SKIP,
COMMAND_MEDIA_STOP,
MEDIA_STATUS_KEY,
} from "../../shared/index.js";
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
const logger = createChildLogger("media.service");
// ---------------------------------------------------------------------------
// Types — match frontend exactly
// ---------------------------------------------------------------------------
export interface MediaItem {
id?: string;
source: string;
title: string;
mode?: "music" | "screen";
durationMs?: number | null;
thumbnailUrl?: string | null;
}
export interface MediaState {
playing: boolean;
/** null/absent when idle; "music" | "screen" while a track is active. */
activeMode?: "music" | "screen" | null;
musicVolume: number;
loop: boolean;
current: MediaItem | null;
queue: MediaItem[];
}
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
const DEFAULT_COMMAND_TIMEOUT_MS = 5000;
const DEFAULT_STATE: MediaState = {
playing: false,
activeMode: null,
musicVolume: 0.3,
loop: false,
current: null,
queue: [],
};
// ---------------------------------------------------------------------------
// Normalisation — handle both boolean (new) and string (legacy) playing values
// ---------------------------------------------------------------------------
function normalizeMediaState(raw: Record<string, unknown>): MediaState {
const rawPlaying = raw.playing;
const playing =
rawPlaying === true ||
rawPlaying === "playing" ||
rawPlaying === "buffering";
const mode = raw.activeMode;
const activeMode: "music" | "screen" | null =
mode === "music" || mode === "screen" ? mode : null;
return {
playing,
activeMode,
musicVolume: Number(raw.musicVolume ?? 0.3),
loop: Boolean(raw.loop ?? false),
current: (raw.current as MediaItem | null) ?? null,
queue: (raw.queue as MediaItem[]) ?? [],
};
}
type MediaReplyData = Record<string, unknown> | MediaState;
function _fromReply(data: MediaReplyData): MediaState {
return normalizeMediaState(data as Record<string, unknown>);
}
async function readStatusFallback(): Promise<MediaState> {
const cached = await readRedisStatus(MEDIA_STATUS_KEY);
return cached ? normalizeMediaState(cached) : DEFAULT_STATE;
}
// ---------------------------------------------------------------------------
// Service methods
// ---------------------------------------------------------------------------
/**
* Read media status from Redis key `media:status` set by discord-gateway.
*/
export async function getStatus(): Promise<MediaState> {
logger.debug("getStatus called");
return readStatusFallback();
}
/**
* Queue a media source via Redis command to discord-gateway.
*/
export async function queue(
source: string,
mode: "music" | "screen" = "music",
): Promise<MediaState> {
logger.info({ source, mode }, "queue called");
return tryCommandThenFallback(
() =>
publishCommand<MediaState>(
COMMAND_MEDIA_QUEUE,
// NOTE: gateway MediaHandler reads `payload.url` (not `source`) —
// keep the field name aligned or playback silently no-ops.
{ url: source, mode },
DEFAULT_COMMAND_TIMEOUT_MS,
),
() => readStatusFallback(),
"queue",
);
}
/**
* Skip current track via Redis command to discord-gateway.
*/
export async function skip(): Promise<MediaState> {
logger.info("skip called");
return tryCommandThenFallback(
() =>
publishCommand<MediaState>(
COMMAND_MEDIA_SKIP,
{},
DEFAULT_COMMAND_TIMEOUT_MS,
),
() => readStatusFallback(),
"skip",
);
}
/**
* Stop playback via Redis command to discord-gateway.
*/
export async function stop(): Promise<MediaState> {
logger.info("stop called");
return tryCommandThenFallback(
() =>
publishCommand<MediaState>(
COMMAND_MEDIA_STOP,
{},
DEFAULT_COMMAND_TIMEOUT_MS,
),
() => readStatusFallback(),
"stop",
);
}
/**
* Toggle loop mode (replay current track on natural end) via Redis command.
*/
export async function setLoop(loop: boolean): Promise<MediaState> {
logger.info({ loop }, "setLoop called");
return tryCommandThenFallback(
() =>
publishCommand<MediaState>(
COMMAND_MEDIA_LOOP,
{ loop },
DEFAULT_COMMAND_TIMEOUT_MS,
),
() => readStatusFallback(),
"setLoop",
);
}
@@ -51,10 +51,51 @@ export interface AttachmentResult {
uploaded_at: number | null;
}
type MessageRow = ReturnType<typeof mapMessageRow>;
export type { MessageRow };
/**
* Build the NULL-safe "exclude spam threads" condition. Non-thread messages
* (NULL thread_id) are always kept; thread messages are kept only when their
* thread is not in the configured exclusion list.
*/
function excludeSpamThreads(): SQL | undefined {
if (EXCLUDED_THREAD_IDS.length === 0) return undefined;
return or(
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
}
/** Normalize a raw attachment DB row to the API shape. */
function mapAttachmentRow(r: Record<string, unknown>): AttachmentResult {
return {
id: String(r.id ?? ""),
message_id: String(r.message_id ?? ""),
guild_id: String(r.guild_id ?? ""),
channel_id: String(r.channel_id ?? ""),
thread_id: (r.thread_id as string | null) ?? null,
user_id: String(r.user_id ?? ""),
filename: String(r.filename ?? ""),
size: Number(r.size ?? 0),
type: String(r.type ?? ""),
discord_url: String(r.discord_url ?? ""),
uploaded_url: (r.uploaded_url as string | null) ?? null,
upload_status: String(r.upload_status ?? "pending"),
upload_error: (r.upload_error as string | null) ?? null,
created_at: Number(r.created_at ?? 0),
uploaded_at: (r.uploaded_at as number | null) ?? null,
};
}
/** Select the first `limit + 1` rows so the caller can derive the next cursor. */
function cursorLimit(limit: number): number {
return limit + 1;
}
export class MessagesRepository {
async findMany(
query: MessageQuery,
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
async findMany(query: MessageQuery): Promise<PageResult<MessageRow>> {
const db = getDatabase();
const limit = query.limit ?? 50;
const conditions: SQL[] = [];
@@ -76,13 +117,8 @@ export class MessagesRepository {
}
// Exclude spam threads (NULL-safe: non-thread messages are kept)
if (EXCLUDED_THREAD_IDS.length > 0) {
const excludeThreads = or(
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
if (excludeThreads) conditions.push(excludeThreads);
}
const excludeThreads = excludeSpamThreads();
if (excludeThreads) conditions.push(excludeThreads);
const where = conditions.length > 0 ? and(...conditions) : undefined;
const rows = await db
@@ -90,7 +126,7 @@ export class MessagesRepository {
.from(pgMessagesTable)
.where(where)
.orderBy(desc(pgMessagesTable.created_at))
.limit(limit + 1);
.limit(cursorLimit(limit));
const data = rows
.slice(0, limit)
@@ -138,7 +174,7 @@ export class MessagesRepository {
async findByChannel(
channelId: string,
query: MessageQuery,
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
): Promise<PageResult<MessageRow>> {
const db = getDatabase();
const limit = query.limit ?? 50;
const conditions: SQL[] = [eq(pgMessagesTable.channel_id, channelId)];
@@ -148,20 +184,15 @@ export class MessagesRepository {
}
// Exclude spam threads (NULL-safe)
if (EXCLUDED_THREAD_IDS.length > 0) {
const excludeThreads = or(
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
if (excludeThreads) conditions.push(excludeThreads);
}
const excludeThreads = excludeSpamThreads();
if (excludeThreads) conditions.push(excludeThreads);
const rows = await db
.select()
.from(pgMessagesTable)
.where(and(...conditions))
.orderBy(desc(pgMessagesTable.created_at))
.limit(limit + 1);
.limit(cursorLimit(limit));
const data = rows
.slice(0, limit)
@@ -181,7 +212,7 @@ export class MessagesRepository {
async *streamMany(
query: MessageQuery,
pageSize = 50,
): AsyncGenerator<ReturnType<typeof mapMessageRow>, void, unknown> {
): AsyncGenerator<MessageRow, void, unknown> {
const conditions: SQL[] = [];
if (query.guildId) {
@@ -196,13 +227,8 @@ export class MessagesRepository {
if (query.status) {
conditions.push(eq(pgMessagesTable.ai_status, query.status));
}
if (EXCLUDED_THREAD_IDS.length > 0) {
const excludeThreads = or(
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
if (excludeThreads) conditions.push(excludeThreads);
}
const excludeThreads = excludeSpamThreads();
if (excludeThreads) conditions.push(excludeThreads);
const where = conditions.length > 0 ? and(...conditions) : undefined;
let cursor: string | undefined = query.cursor;
@@ -221,7 +247,7 @@ export class MessagesRepository {
.from(pgMessagesTable)
.where(pageWhere)
.orderBy(desc(pgMessagesTable.created_at))
.limit(pageSize + 1);
.limit(cursorLimit(pageSize));
if (rows.length === 0) return;
@@ -366,31 +392,30 @@ export class MessagesRepository {
async getImageMessages(
guildId: string,
limit: number = 50,
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
): Promise<PageResult<MessageRow>> {
const db = getDatabase();
// Subquery: find distinct message_ids from attachments with image MIME type
const attachmentConditions: SQL[] = [
eq(pgAttachmentsTable.guild_id, guildId),
like(pgAttachmentsTable.type, "image/%"),
];
// Exclude spam threads (NULL-safe for non-thread messages)
const excludeThreads =
EXCLUDED_THREAD_IDS.length > 0
? or(
isNull(pgAttachmentsTable.thread_id),
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
)
: undefined;
if (excludeThreads) attachmentConditions.push(excludeThreads);
const imageMsgIds = db
.select({ id: pgAttachmentsTable.message_id })
.from(pgAttachmentsTable)
.where(
and(
eq(pgAttachmentsTable.guild_id, guildId),
like(pgAttachmentsTable.type, "image/%"),
// Exclude spam threads (NULL-safe for non-thread messages)
...(EXCLUDED_THREAD_IDS.length > 0
? (() => {
const excludeThreads = or(
isNull(pgAttachmentsTable.thread_id),
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
);
return excludeThreads ? [excludeThreads] : [];
})()
: []),
),
)
.where(and(...attachmentConditions))
.orderBy(desc(pgAttachmentsTable.created_at))
.limit(limit + 1);
.limit(cursorLimit(limit));
// Fetch full message rows for those IDs
const rows = await db
@@ -398,7 +423,7 @@ export class MessagesRepository {
.from(pgMessagesTable)
.where(inArray(pgMessagesTable.id, imageMsgIds))
.orderBy(desc(pgMessagesTable.created_at))
.limit(limit + 1);
.limit(cursorLimit(limit));
const data = rows
.slice(0, limit)
@@ -433,28 +458,16 @@ export class MessagesRepository {
.from(pgAttachmentsTable)
.where(and(...conditions))
.orderBy(desc(pgAttachmentsTable.created_at))
.limit(limit + 1);
.limit(cursorLimit(limit));
const data = rows.map((r) => ({
id: String(r.id ?? ""),
message_id: String(r.message_id ?? ""),
guild_id: String(r.guild_id ?? ""),
channel_id: String(r.channel_id ?? ""),
thread_id: (r.thread_id as string | null) ?? null,
user_id: String(r.user_id ?? ""),
filename: String(r.filename ?? ""),
size: Number(r.size ?? 0),
type: String(r.type ?? ""),
discord_url: String(r.discord_url ?? ""),
uploaded_url: (r.uploaded_url as string | null) ?? null,
upload_status: String(r.upload_status ?? "pending"),
upload_error: (r.upload_error as string | null) ?? null,
created_at: Number(r.created_at ?? 0),
uploaded_at: (r.uploaded_at as number | null) ?? null,
}));
const data = rows.map((r) =>
mapAttachmentRow(r as Record<string, unknown>),
);
// nextCursor derives from the fetched-but-untrimmed overflow row (index
// `limit`), matching the other cursor-paginated queries.
const nextCursor =
data.length > limit ? String(data[limit].created_at) : null;
rows.length > limit ? String(rows[limit].created_at) : null;
const trimmed = data.slice(0, limit);
return { data: trimmed, nextCursor };
@@ -522,6 +535,44 @@ export class MessagesRepository {
username: r.username ? String(r.username) : null,
}));
}
/**
* Distinct guilds present in the message archive (drives the guild picker).
*/
async listGuilds(): Promise<
Array<{ id: string; name: string; icon: string | null }>
> {
const db = getDatabase();
const rows = await db
.selectDistinct({ guild_id: pgMessagesTable.guild_id })
.from(pgMessagesTable)
.orderBy(pgMessagesTable.guild_id);
return rows.map((row) => ({
id: String(row.guild_id ?? ""),
name: `Guild ${String(row.guild_id).slice(0, 8)}`,
icon: null,
}));
}
/**
* Text channels for a guild, derived from the message archive
* (drives the channel picker).
*/
async listTextChannels(
guildId: string,
): Promise<Array<{ id: string; name: string; type: "text" }>> {
const db = getDatabase();
const rows = await db
.selectDistinct({ channel_id: pgMessagesTable.channel_id })
.from(pgMessagesTable)
.where(eq(pgMessagesTable.guild_id, guildId))
.orderBy(pgMessagesTable.channel_id);
return rows.map((row) => ({
id: String(row.channel_id ?? ""),
name: `Channel ${String(row.channel_id).slice(0, 8)}`,
type: "text" as const,
}));
}
}
export const messagesRepository = new MessagesRepository();
@@ -2,14 +2,16 @@ import { config } from "@/shared/config/index";
import { NotFoundError, ValidationError } from "@/shared/errors/index";
import { createChildLogger } from "@/shared/logger/index";
import { embedQuery } from "./embed.js";
import { messagesRepository } from "./messages.repository.js";
import { type MessageRow, messagesRepository } from "./messages.repository.js";
import type { MessageQuery, SemanticSearchQuery } from "./messages.schema.js";
import { searchArchive } from "./qdrant.js";
const logger = createChildLogger("messages.service");
export class MessagesService {
async listMessages(query: MessageQuery) {
async listMessages(
query: MessageQuery,
): Promise<Awaited<ReturnType<typeof messagesRepository.findMany>>> {
if (!query.channelId && !query.guildId) {
throw new ValidationError("Either channelId or guildId is required");
}
@@ -26,7 +28,10 @@ export class MessagesService {
return messagesRepository.streamMany(query, pageSize);
}
async getMessagesByChannel(channelId: string, query: MessageQuery) {
async getMessagesByChannel(
channelId: string,
query: MessageQuery,
): Promise<Awaited<ReturnType<typeof messagesRepository.findByChannel>>> {
if (!channelId) {
throw new ValidationError("channelId is required");
}
@@ -35,7 +40,14 @@ export class MessagesService {
return messagesRepository.findByChannel(channelId, query);
}
async getMessageById(id: string) {
async getMessageById(id: string): Promise<
NonNullable<Awaited<ReturnType<typeof messagesRepository.findById>>> & {
edit_count: number;
edit_history: Awaited<
ReturnType<typeof messagesRepository.getEditHistory>
>;
}
> {
if (!id) {
throw new ValidationError("message ID is required");
}
@@ -56,7 +68,12 @@ export class MessagesService {
};
}
async getAttachmentsByChannel(channelId: string, query: MessageQuery) {
async getAttachmentsByChannel(
channelId: string,
query: MessageQuery,
): Promise<
Awaited<ReturnType<typeof messagesRepository.getAttachmentsByChannel>>
> {
if (!channelId) {
throw new ValidationError("channelId is required");
}
@@ -111,14 +128,33 @@ export class MessagesService {
return { results, nextCursor: null };
}
async getActivity(days = 30) {
async getActivity(
days = 30,
): Promise<Awaited<ReturnType<typeof messagesRepository.getActivity>>> {
return messagesRepository.getActivity(days);
}
async getRecentEdits(limit = 50, channelId?: string) {
async getRecentEdits(
limit = 50,
channelId?: string,
): Promise<Awaited<ReturnType<typeof messagesRepository.getRecentEdits>>> {
logger.debug({ limit, channelId }, "Getting recent message edits");
return messagesRepository.getRecentEdits(limit, channelId);
}
/** Distinct guilds present in the message archive (guild picker). */
async getGuilds(): Promise<
Awaited<ReturnType<typeof messagesRepository.listGuilds>>
> {
return messagesRepository.listGuilds();
}
/** Text channels for a guild (channel picker). */
async getTextChannels(
guildId: string,
): Promise<Awaited<ReturnType<typeof messagesRepository.listTextChannels>>> {
return messagesRepository.listTextChannels(guildId);
}
}
/** Shape returned to the frontend (text + rich metadata from the archive payload). */
@@ -1,184 +0,0 @@
import {
and,
desc,
eq,
gte,
ilike,
lt,
lte,
or,
type SQL,
sql,
} from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
import { pgVoiceRecordingsTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
const logger = createChildLogger("recordings.service");
export interface RecordingRow {
id: string;
user_id: string;
username: string;
avatar_url: string | null;
guild_id: string | null;
channel_id: string | null;
channel_name: string | null;
filename: string;
size_bytes: number;
download_url: string | null;
upload_status: string;
upload_error: string | null;
created_at: number;
uploaded_at: number | null;
transcription: string | null;
}
export interface PaginatedRecordings {
items: RecordingRow[];
nextCursor: string | null;
hasMore: boolean;
}
export interface RecordingFilters {
channelId?: string;
userId?: string;
cursor?: string;
/** keyword search against transcription + username (ILIKE) */
q?: string;
/** created_at lower bound (epoch ms) */
startDate?: number;
/** created_at upper bound (epoch ms) */
endDate?: number;
}
export interface SpeakerSummary {
user_id: string;
username: string;
avatar_url: string | null;
clips: number;
/** summed MP3 bytes; ~128kbps → est duration in seconds */
est_duration_s: number;
words: number;
transcribed: number;
last_at: number;
}
export class RecordingsService {
async getRecent(
limit = 50,
filters: RecordingFilters = {},
): Promise<PaginatedRecordings> {
logger.info({ limit, filters }, "getRecent called");
const db = getDatabase();
const conditions: SQL[] = [];
if (filters.cursor) {
conditions.push(
lt(pgVoiceRecordingsTable.created_at, Number(filters.cursor)),
);
}
if (filters.channelId) {
conditions.push(eq(pgVoiceRecordingsTable.channel_id, filters.channelId));
}
if (filters.userId) {
conditions.push(eq(pgVoiceRecordingsTable.user_id, filters.userId));
}
if (filters.q) {
const like = `%${filters.q}%`;
const qOr = or(
ilike(pgVoiceRecordingsTable.transcription, like),
ilike(pgVoiceRecordingsTable.username, like),
);
if (qOr) conditions.push(qOr);
}
if (filters.startDate) {
conditions.push(
gte(pgVoiceRecordingsTable.created_at, filters.startDate),
);
}
if (filters.endDate) {
conditions.push(lte(pgVoiceRecordingsTable.created_at, filters.endDate));
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const allRows = await db
.select({
id: pgVoiceRecordingsTable.id,
user_id: pgVoiceRecordingsTable.user_id,
username: pgVoiceRecordingsTable.username,
avatar_url: pgVoiceRecordingsTable.avatar_url,
guild_id: pgVoiceRecordingsTable.guild_id,
channel_id: pgVoiceRecordingsTable.channel_id,
channel_name: pgVoiceRecordingsTable.channel_name,
filename: pgVoiceRecordingsTable.filename,
size_bytes: pgVoiceRecordingsTable.size_bytes,
download_url: pgVoiceRecordingsTable.download_url,
upload_status: pgVoiceRecordingsTable.upload_status,
upload_error: pgVoiceRecordingsTable.upload_error,
created_at: pgVoiceRecordingsTable.created_at,
uploaded_at: pgVoiceRecordingsTable.uploaded_at,
transcription: pgVoiceRecordingsTable.transcription,
})
.from(pgVoiceRecordingsTable)
.where(where)
.orderBy(desc(pgVoiceRecordingsTable.created_at))
.limit(limit + 1);
const items = allRows.slice(0, limit) as unknown as RecordingRow[];
const hasMore = allRows.length > limit;
const nextCursor = hasMore
? String(items[items.length - 1]?.created_at)
: null;
return { items, nextCursor, hasMore };
}
async deleteById(id: string): Promise<void> {
const db = getDatabase();
await db
.delete(pgVoiceRecordingsTable)
.where(eq(pgVoiceRecordingsTable.id, id));
}
/**
* Speaker leaderboard: aggregate clips / est. duration / transcribed words
* per user. `est_duration_s` derives from summed MP3 bytes at the uploader's
* 128 kbps transcode rate; `words` sums transcription word counts.
*/
async getSummary(): Promise<SpeakerSummary[]> {
const db = getDatabase();
const t = pgVoiceRecordingsTable;
const rows = await db
.select({
user_id: t.user_id,
username: t.username,
avatar_url: t.avatar_url,
clips: sql<number>`count(*)::int`,
total_bytes: sql<number>`coalesce(sum(${t.size_bytes}), 0)`,
words: sql<number>`coalesce(sum(array_length(string_to_array(${t.transcription}, ' '), 1)), 0)::int`,
transcribed: sql<number>`count(${t.transcription})::int`,
last_at: sql<number>`max(${t.created_at})`,
})
.from(t)
.groupBy(t.user_id, t.username, t.avatar_url)
.orderBy(sql`clips desc, last_at desc`);
return rows.map((r) => ({
user_id: r.user_id,
username: r.username,
avatar_url: r.avatar_url,
clips: r.clips,
// 128 kbps = 128000 bps → bytes*8/128000 = seconds
est_duration_s: Math.round((r.total_bytes * 8) / 128000),
words: r.words,
transcribed: r.transcribed,
last_at: r.last_at,
}));
}
}
export const recordingsService = new RecordingsService();
@@ -1,111 +0,0 @@
/**
* Authoritative live-voice store.
*
* Single source of truth for who is present / speaking in voice. The backend
* WebSocket server is the one relay every frontend client connects to, so it
* is the correct place to aggregate the gateway's `voice_active_user` deltas
* into a shared snapshot. A late-joining browser must be able to see the same
* state as everyone else — this store makes that possible (seeded into the WS
* initial states and served via GET /api/voice/status).
*/
export interface LiveSpeaker {
userId: string;
username: string;
avatar?: string | null;
speaking: boolean;
/** Epoch ms of the most recent activity (start OR end of speech). */
lastActiveAt: number;
}
const speakers = new Map<string, LiveSpeaker>();
const MAX_SPEAKERS = 200;
/**
* Speakers inactive for longer than this are auto-expired from the snapshot.
* This handles the case where the gateway disconnects abruptly and never
* sends `speaking: false` for active users.
*/
const SPEAKER_TTL_MS = 30_000;
/** Purge speakers that haven't been active recently. */
function purgeStale(): void {
const cutoff = Date.now() - SPEAKER_TTL_MS;
for (const [id, s] of speakers) {
if (!s.speaking && s.lastActiveAt < cutoff) {
speakers.delete(id);
}
}
}
/**
* Record a voice_active_user event. `speaking: true` upserts the speaker as
* active; `speaking: false` marks them inactive while keeping them briefly
* for the activity timeline (until TTL expiry).
*/
export function recordSpeaker(data: {
userId: string;
username?: string;
avatar?: string | null;
speaking: boolean;
}): void {
const { userId, speaking } = data;
const existing = speakers.get(userId);
const speaker: LiveSpeaker = {
userId,
username: data.username ?? existing?.username ?? "Unknown",
avatar: data.avatar ?? existing?.avatar ?? null,
speaking,
lastActiveAt: Date.now(),
};
if (speakers.size >= MAX_SPEAKERS && !existing) {
let oldestId: string | null = null;
let oldestTs = Infinity;
for (const [id, s] of speakers) {
if (!s.speaking && s.lastActiveAt < oldestTs) {
oldestTs = s.lastActiveAt;
oldestId = id;
}
}
if (oldestId) speakers.delete(oldestId);
else return;
}
speakers.set(userId, speaker);
}
/**
* All recently-active speakers, most recently active first.
* Stale (non-speaking + old) entries are auto-purged.
*/
export function getActiveSpeakers(): LiveSpeaker[] {
purgeStale();
return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt);
}
/** Only speakers currently flagged as speaking. */
export function getSpeakingSpeakers(): LiveSpeaker[] {
purgeStale();
return [...speakers.values()]
.filter((s) => s.speaking)
.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
}
/**
* Mark ALL tracked speakers as not-speaking and purge stale ones.
* Called when the gateway disconnects from voice — ensures the authoritative
* snapshot doesn't carry ghost speakers.
*/
export function clearAllSpeakers(): void {
for (const [, s] of speakers) {
s.speaking = false;
}
purgeStale();
}
/** Drop all tracked speakers (used on backend restart). */
export function resetLiveSpeakers(): void {
speakers.clear();
}
@@ -1,13 +0,0 @@
import { z } from "zod";
export const connectVoiceSchema = z.object({
guildId: z.string().min(1, "guildId is required"),
channelId: z.string().min(1, "channelId is required"),
});
export const voiceCommandSchema = z.object({
command: z.string().min(1, "command is required"),
});
export type ConnectVoiceInput = z.infer<typeof connectVoiceSchema>;
export type VoiceCommandInput = z.infer<typeof voiceCommandSchema>;
@@ -1,193 +0,0 @@
import { eq } from "drizzle-orm";
import {
createChildLogger,
tryCommandThenFallback,
} from "../../shared/commandHelper.js";
import { getDatabase } from "../../shared/database/index.js";
import {
COMMAND_GUILDS_LIST,
COMMAND_GUILDS_TEXT_CHANNELS,
COMMAND_VOICE_CHANNELS,
COMMAND_VOICE_CONNECT,
COMMAND_VOICE_DISCONNECT,
type CommandReply,
pgMessagesTable,
VOICE_STATUS_KEY,
} from "../../shared/index.js";
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
import { getActiveSpeakers, type LiveSpeaker } from "./live-speaker.js";
const logger = createChildLogger("voice.service");
export interface Guild {
id: string;
name: string;
icon: string | null;
}
export interface Channel {
id: string;
name: string;
type: "voice" | "text";
/** Whether the selfbot account can actually join this voice channel. */
joinable?: boolean;
}
export interface GuildVoiceEntry {
guildId: string;
channelId: string;
channelName: string;
connectedAt: number;
}
export interface VoiceStatus {
connected: boolean;
activeGuildId: string | null;
activeChannelId: string | null;
activeChannelName: string | null;
connections: GuildVoiceEntry[];
/**
* Authoritative shared voice snapshot — who is present / speaking right
* now, aggregated server-side from the gateway's `voice_active_user`
* deltas. All browsers converge on this same list.
*/
activeSpeakers: LiveSpeaker[];
}
export const DEFAULT_VOICE_STATUS: VoiceStatus = {
connected: false,
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
connections: [],
activeSpeakers: [],
};
/** Attach the live speaker snapshot to any voice status payload. */
function withActiveSpeakers<T extends Partial<VoiceStatus>>(
status: T,
): T & { activeSpeakers: LiveSpeaker[] } {
return { ...status, activeSpeakers: getActiveSpeakers() };
}
/**
* Wraps tryCommandThenFallback with a cleaner signature for use within this module.
* Attempts a Redis command first; on failure, falls back to the provided function.
*/
async function withFallback<T>(
commandFn: () => Promise<CommandReply<T> | null>,
fallbackFn: () => Promise<T>,
name: string,
): Promise<T> {
return tryCommandThenFallback(commandFn, fallbackFn, name);
}
function readVoiceStatusFallback(): Promise<VoiceStatus> {
return readRedisStatus(VOICE_STATUS_KEY).then((cached) =>
withActiveSpeakers(
(cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
),
);
}
/**
* Get guilds — query from discord-gateway via Redis command for real names.
* Falls back to database (distinct guild_id from messages) if gateway unreachable.
*/
export async function getGuilds(): Promise<Guild[]> {
logger.info("getGuilds called");
return withFallback(
() => publishCommand<Guild[]>(COMMAND_GUILDS_LIST, {}),
async () => {
const db = getDatabase();
const rows = await db
.selectDistinct({ guild_id: pgMessagesTable.guild_id })
.from(pgMessagesTable)
.orderBy(pgMessagesTable.guild_id);
return rows.map((row) => ({
id: String(row.guild_id ?? ""),
name: `Guild ${String(row.guild_id).slice(0, 8)}`,
icon: null,
}));
},
"getGuilds",
);
}
/**
* Get text channels — query from discord-gateway via Redis command for real names.
* Falls back to database if gateway unreachable.
*/
export async function getTextChannels(guildId: string): Promise<Channel[]> {
logger.info({ guildId }, "getTextChannels called");
return withFallback(
() => publishCommand<Channel[]>(COMMAND_GUILDS_TEXT_CHANNELS, { guildId }),
async () => {
const db = getDatabase();
const rows = await db
.selectDistinct({ channel_id: pgMessagesTable.channel_id })
.from(pgMessagesTable)
.where(eq(pgMessagesTable.guild_id, guildId))
.orderBy(pgMessagesTable.channel_id);
return rows.map((row) => ({
id: String(row.channel_id ?? ""),
name: `Channel ${String(row.channel_id).slice(0, 8)}`,
type: "text" as const,
}));
},
"getTextChannels",
);
}
/**
* Get voice channels — query from discord-gateway via Redis command.
*/
export async function getVoiceChannels(guildId: string): Promise<Channel[]> {
logger.info({ guildId }, "getVoiceChannels called");
const reply = await publishCommand<Channel[]>(COMMAND_VOICE_CHANNELS, {
guildId,
});
return reply?.success && reply.data ? reply.data : [];
}
/**
* Get current voice connection status from Redis cache set by discord-gateway.
*/
export async function getVoiceStatus(): Promise<VoiceStatus> {
logger.debug("getVoiceStatus called");
const cached = await readRedisStatus(VOICE_STATUS_KEY);
return withActiveSpeakers(
(cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
);
}
/**
* Connect to a voice channel via Redis command to discord-gateway.
*/
export async function connectVoice(
guildId: string,
channelId: string,
): Promise<VoiceStatus> {
logger.info({ guildId, channelId }, "connectVoice called");
return withFallback(
() =>
publishCommand<VoiceStatus>(COMMAND_VOICE_CONNECT, {
guildId,
channelId,
}),
() => readVoiceStatusFallback(),
"connectVoice",
);
}
/**
* Disconnect from voice via Redis command to discord-gateway.
*/
export async function disconnectVoice(): Promise<VoiceStatus> {
logger.info("disconnectVoice called");
return withFallback(
() => publishCommand<VoiceStatus>(COMMAND_VOICE_DISCONNECT, {}),
() => readVoiceStatusFallback(),
"disconnectVoice",
);
}
+4 -106
View File
@@ -5,33 +5,13 @@ import { chatRequestSchema } from "../modules/chatbot/chatbot.schema";
import { chatbotService } from "../modules/chatbot/chatbot.service";
import { dashboardService } from "../modules/dashboard/dashboard.service";
import { knowledgeService } from "../modules/knowledge/knowledge.service";
import {
mediaLoopSchema,
mediaQueueSchema,
} from "../modules/media/media.schema";
import {
getStatus,
queue,
setLoop,
skip,
stop,
} from "../modules/media/media.service";
import {
messageQuerySchema,
semanticSearchSchema,
} from "../modules/messages/messages.schema";
import { messagesService } from "../modules/messages/messages.service";
import { moderationService } from "../modules/moderation/moderation.service";
import { recordingsService } from "../modules/recordings/recordings.service";
import { uiStateService } from "../modules/ui-state/ui-state.service";
import {
connectVoice,
disconnectVoice,
getGuilds,
getTextChannels,
getVoiceChannels,
getVoiceStatus,
} from "../modules/voice/voice.service";
import { config } from "../shared/config/index";
import { publishCommandNoReply } from "../shared/redis/index";
@@ -89,6 +69,10 @@ const dashboardRouter = {
// ── Messages ─────────────────────────────────────────────────────
const messagesRouter = {
guilds: os.handler(() => messagesService.getGuilds()),
textChannels: os
.input(z.object({ guildId: z.string() }))
.handler(({ input }) => messagesService.getTextChannels(input.guildId)),
list: os
.input(messageQuerySchema)
.handler(({ input }) => messagesService.listMessages(input)),
@@ -238,86 +222,6 @@ const moderationRouter = {
.handler(({ input }) => moderationService.getCoverage(input.days)),
};
// ── Media ────────────────────────────────────────────────────────
const mediaRouter = {
status: os.handler(() => getStatus()),
queue: os.input(mediaQueueSchema).handler(async ({ input }) => {
await queue(input.source, input.mode);
return getStatus();
}),
skip: os.handler(async () => {
await skip();
return getStatus();
}),
stop: os.handler(async () => {
await stop();
return getStatus();
}),
loop: os.input(mediaLoopSchema).handler(async ({ input }) => {
await setLoop(input.loop);
return getStatus();
}),
};
// ── Voice ─────────────────────────────────────────────────────────
const voiceRouter = {
guilds: os.handler(() => getGuilds()),
textChannels: os
.input(z.object({ guildId: z.string() }))
.handler(({ input }) => getTextChannels(input.guildId)),
voiceChannels: os
.input(z.object({ guildId: z.string() }))
.handler(({ input }) => getVoiceChannels(input.guildId)),
status: os.handler(() => getVoiceStatus()),
connect: os
.input(z.object({ guildId: z.string(), channelId: z.string() }))
.handler(async ({ input }) => {
await connectVoice(input.guildId, input.channelId);
return getVoiceStatus();
}),
disconnect: os.handler(async () => {
await disconnectVoice();
return getVoiceStatus();
}),
command: os
.input(z.object({ command: z.string().min(1) }))
.handler(async ({ input }) => {
await publishCommandNoReply(input.command);
return { success: true, command: input.command };
}),
};
// ── Recordings ───────────────────────────────────────────────────
const recordingsRouter = {
list: os
.input(
z.object({
limit: z.coerce.number().int().positive().default(50),
channelId: z.string().optional(),
userId: z.string().optional(),
cursor: z.string().optional(),
q: z.string().optional(),
startDate: z.coerce.number().int().optional(),
endDate: z.coerce.number().int().optional(),
}),
)
.handler(({ input }) =>
recordingsService.getRecent(input.limit, {
channelId: input.channelId,
userId: input.userId,
cursor: input.cursor,
q: input.q,
startDate: input.startDate,
endDate: input.endDate,
}),
),
delete: os.input(z.object({ id: z.string() })).handler(async ({ input }) => {
await recordingsService.deleteById(input.id);
return { ok: true };
}),
summary: os.handler(async () => recordingsService.getSummary()),
};
// ── Analysis (search) ──────────────────────────────────────────────
const analysisRouter = {
search: os
@@ -416,11 +320,8 @@ const configRouter = {
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
voiceGuildId: config.VOICE_GUILD_ID || null,
voiceChannelId: config.VOICE_CHANNEL_ID || null,
logLevel: config.LOG_LEVEL,
})),
};
@@ -438,9 +339,6 @@ export const appRouter = {
dashboard: dashboardRouter,
messages: messagesRouter,
moderation: moderationRouter,
media: mediaRouter,
voice: voiceRouter,
recordings: recordingsRouter,
analysis: analysisRouter,
chatbot: chatbotRouter,
config: configRouter,
+2 -45
View File
@@ -33,29 +33,6 @@ export const configSchema = z
.transform((v) => v.split(",").filter(Boolean))
.describe("Thread IDs to exclude from capture"),
// ── Legacy voice ─────────────────────────────────────────────────────
VOICE_GUILD_ID: z.string().min(1).optional(),
VOICE_CHANNEL_ID: z.string().min(1).optional(),
// ── Recording ────────────────────────────────────────────────────────
RECORDINGS_DIR: z.string().default("./recordings"),
RECORDING_SEGMENT_MS: z.coerce.number().positive().default(5000),
// ── Decoder ──────────────────────────────────────────────────────────
DECODER_ROTATE_MS: z.coerce.number().positive().default(5000),
DECODER_COOLDOWN_MS: z.coerce.number().positive().default(30000),
// ── Audio ────────────────────────────────────────────────────────────
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),
// ── Server ───────────────────────────────────────────────────────────
WEBSERVER_PORT: z.coerce.number().positive().default(3001),
NODE_ENV: z
@@ -92,17 +69,8 @@ export const configSchema = z
// ── Redis ────────────────────────────────────────────────────────────
REDIS_URL: z.string().default("redis://localhost:6379"),
// ── Voice PCM WebSocket (direct gateway→backend, bypasses Redis) ────
VOICE_PCM_WS_ENABLED: z
.string()
.optional()
.transform((v) => v === "true")
.default(true),
BACKEND_WS_URL: z.string().default("ws://backend:3000/ws"),
BACKEND_WS_TOKEN: z.string().optional().default(""),
// ── Connection ───────────────────────────────────────────────────────
VOICE_CONNECTION_TIMEOUT_MS: z.coerce.number().positive().default(15000),
RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000),
// ── Attachments ─────────────────────────────────────────────────────
@@ -203,13 +171,6 @@ export const configSchema = z
PISCINA_MAX_THREADS: z.coerce.number().int().positive().optional(),
PISCINA_MEDIA_MAX_THREADS: z.coerce.number().int().positive().optional(),
// ── Voice Transcription ────────────────────────────────────────────────
AI_VOICE_TRANSCRIPTION_ENABLED: z
.string()
.optional()
.transform((v) => v === "true")
.default(false),
// ── OpenAI Moderation ───────────────────────────────────────────────
OPENAI_MODERATION_API_KEY: z.string().optional(),
OPENAI_MODERATION_BASE_URL: z
@@ -252,7 +213,6 @@ export const configSchema = z
// ── Retention ───────────────────────────────────────────────────────
RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0),
RETENTION_ATTACHMENTS_DAYS: z.coerce.number().int().min(0).default(0),
RETENTION_VOICE_DAYS: z.coerce.number().int().min(0).default(0),
RETENTION_CLEANUP_INTERVAL_MS: z.coerce
.number()
.positive()
@@ -269,9 +229,8 @@ export const configSchema = z
.default(true),
})
.superRefine((value, ctx) => {
if (!value.AI_ANALYSIS_ENABLED) {
// skip: AI analysis not enabled
} else if (!value.AI_LLM_API_KEY) {
// AI analysis requires credentials to run.
if (value.AI_ANALYSIS_ENABLED && !value.AI_LLM_API_KEY) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["AI_LLM_API_KEY"],
@@ -291,7 +250,6 @@ export const configSchema = z
export type AppConfig = z.infer<typeof configSchema> & {
EFFECTIVE_TEXT_GUILD_ID?: string;
EFFECTIVE_VOICE_GUILD_ID?: string;
EFFECTIVE_MONITOR_GUILD_IDS: string[];
};
@@ -301,7 +259,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
return {
...parsed,
EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID,
EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID,
EFFECTIVE_MONITOR_GUILD_IDS:
parsed.MONITOR_GUILD_IDS.length > 0
? parsed.MONITOR_GUILD_IDS
@@ -24,9 +24,6 @@ export interface BroadcasterClient {
messageAnalyzed: (data: unknown) => void;
attachmentCreated: (data: unknown) => void;
attachmentUploaded: (data: unknown) => void;
voiceRecordingStarted: (data: unknown) => void;
voiceRecordingStopped: (data: unknown) => void;
voiceRecordingUploaded: (data: unknown) => void;
analysisQueueStatus: (data: unknown) => void;
}
@@ -102,17 +99,6 @@ export interface AttachmentRecord {
uploaded_at: number | null;
}
export interface VoiceSegmentRecord {
id: string;
user_id: string;
session_id: string;
guild_id: string;
channel_id: string;
filename: string;
duration_ms: number;
created_at: number;
}
export interface DashboardMessage {
id: string;
channel_id: string;
@@ -121,7 +107,7 @@ export interface DashboardMessage {
avatar_url: string | null;
content: string;
created_at: number;
type: "text" | "image" | "voice";
type: "text" | "image";
}
export interface MessageQuery {
@@ -154,23 +140,6 @@ export interface AnalysisResult {
evidence?: string[];
}
export interface VoiceRecordingUploadData {
id: string;
user_id: string;
username: string;
avatar_url: string | null;
guild_id: string | null;
channel_id: string | null;
channel_name: string | null;
filename: string;
size_bytes: number;
download_url: string;
upload_status: string;
created_at: number;
uploaded_at: number;
transcription?: string | null;
}
export interface AnalysisQueueStatus {
queuedConversations: number;
activeRequests: number;
@@ -221,7 +190,6 @@ export interface RetentionPolicy {
channel_id: string | null;
retention_days: number;
apply_to_media: boolean;
apply_to_voice: boolean;
enabled: boolean;
created_at: number;
updated_at: number;
@@ -15,11 +15,6 @@ export const DISCORD_MESSAGE_DELETED = "discord:message:deleted";
export const DISCORD_MESSAGE_ANALYZED = "discord:message:analyzed";
export const DISCORD_ATTACHMENT_CREATED = "discord:attachment:created";
export const DISCORD_ATTACHMENT_UPLOADED = "discord:attachment:uploaded";
export const DISCORD_VOICE_STARTED = "discord:voice:started";
export const DISCORD_VOICE_STOPPED = "discord:voice:stopped";
export const DISCORD_VOICE_UPLOADED = "discord:voice:uploaded";
export const DISCORD_VOICE_ACTIVE_USER = "discord:voice:active_user";
export const DISCORD_VOICE_PCM = "discord:voice:pcm";
export const DISCORD_ANALYSIS_QUEUE_STATUS = "discord:analysis:queue_status";
export const DISCORD_REACTION_ADDED = "discord:reaction:added";
export const DISCORD_REACTION_REMOVED = "discord:reaction:removed";
@@ -37,35 +32,15 @@ export const DISCORD_MODERATION_ACTION = "discord:moderation:action";
// ---------------------------------------------------------------------------
export const BACKEND_COMMAND = "backend:command";
export const BACKEND_VOICE_TRANSMIT = "backend:voice:transmit";
export const BACKEND_COMMAND_REPLY_PREFIX = "backend:command:reply:";
// ---------------------------------------------------------------------------
// Status keys (set by discord-gateway, read by backend via Redis GET)
// ---------------------------------------------------------------------------
export const VOICE_STATUS_KEY = "voice:status";
export const MEDIA_STATUS_KEY = "media:status";
// ---------------------------------------------------------------------------
// Command types (used as the `type` field in CommandMessage envelopes)
// ---------------------------------------------------------------------------
export const COMMAND_VOICE_CONNECT = "voice:connect";
export const COMMAND_VOICE_DISCONNECT = "voice:disconnect";
export const COMMAND_VOICE_DISCONNECT_GUILD = "voice:disconnect:guild";
export const COMMAND_VOICE_CHANNELS = "voice:channels";
export const COMMAND_VOICE_TRANSMIT_START = "voice:transmit:start";
export const COMMAND_VOICE_TRANSMIT_STOP = "voice:transmit:stop";
export const COMMAND_GUILDS_LIST = "guilds:list";
export const COMMAND_GUILDS_TEXT_CHANNELS = "guilds:text-channels";
export const COMMAND_MEDIA_QUEUE = "media:queue";
export const COMMAND_MEDIA_SKIP = "media:skip";
export const COMMAND_MEDIA_STOP = "media:stop";
export const COMMAND_MEDIA_VOLUME = "media:volume";
export const COMMAND_MEDIA_LOOP = "media:loop";
export const COMMAND_MODERATION_ACTION = "moderation:action";
export const DISCORD_VOICE_ANALYZED = "discord:voice:analyzed";
// ---------------------------------------------------------------------------
// Event envelope — used by discord-gateway when publishing to Redis
@@ -111,13 +86,7 @@ export const DISCORD_CHANNEL_TO_WS_EVENT: Record<string, string> = {
[DISCORD_MESSAGE_ANALYZED]: "message_analyzed",
[DISCORD_ATTACHMENT_CREATED]: "attachment_created",
[DISCORD_ATTACHMENT_UPLOADED]: "attachment_uploaded",
[DISCORD_VOICE_STARTED]: "voice_recording_started",
[DISCORD_VOICE_STOPPED]: "voice_recording_stopped",
[DISCORD_VOICE_UPLOADED]: "voice_recording_uploaded",
[DISCORD_ANALYSIS_QUEUE_STATUS]: "analysis_queue_status",
[DISCORD_VOICE_ACTIVE_USER]: "voice_active_user",
[DISCORD_VOICE_PCM]: "voice_pcm_data",
[DISCORD_VOICE_ANALYZED]: "voice_analyzed",
[DISCORD_REACTION_ADDED]: "reaction_added",
[DISCORD_REACTION_REMOVED]: "reaction_removed",
[DISCORD_THREAD_CREATED]: "thread_created",
+1 -8
View File
@@ -46,18 +46,11 @@ export function clearBroadcastFunctions(): void {
logger.info("Broadcast functions cleared");
}
function shouldLog(type: string): boolean {
if (!_enabled) return false;
// Avoid logging high-volume events
if (type === "voice_pcm_data") return false;
return true;
}
/**
* Broadcast a JSON event to all connected WebSocket clients.
*/
export function broadcastEvent(type: string, data: unknown): void {
if (shouldLog(type)) {
if (_enabled) {
logger.debug({ event: type }, "Broadcasting event");
}
_broadcast?.(type, data);
+12 -75
View File
@@ -1,23 +1,22 @@
import Redis from "ioredis";
import {
clearAllSpeakers,
recordSpeaker,
} from "../modules/voice/live-speaker.js";
import { config } from "../shared/config/index.js";
import {
DISCORD_CHANNEL_TO_WS_EVENT,
DISCORD_VOICE_ACTIVE_USER,
DISCORD_VOICE_PCM,
DISCORD_VOICE_STOPPED,
} from "../shared/index.js";
import { DISCORD_CHANNEL_TO_WS_EVENT } from "../shared/index.js";
import { createChildLogger } from "../shared/logger/index.js";
import { broadcastBinary, broadcastEvent } from "./broadcast.js";
import { broadcastEvent } from "./broadcast.js";
const logger = createChildLogger("ws.redis-bridge");
/** Channels we subscribe to = all keys in DISCORD_CHANNEL_TO_WS_EVENT */
const SUBSCRIPTION_CHANNELS = Object.keys(DISCORD_CHANNEL_TO_WS_EVENT);
/** Shape of the DiscordGatewayEvent envelope published by the gateway. */
interface GatewayEnvelope {
type?: string;
data?: unknown;
timestamp?: number;
source?: string;
}
let subscriber: Redis | null = null;
function createSubscriber(): Redis {
@@ -31,14 +30,9 @@ function handleSubscriptionMessage(channel: string, message: string): void {
return;
}
let envelope: {
type?: string;
data?: unknown;
timestamp?: number;
source?: string;
};
let envelope: GatewayEnvelope;
try {
envelope = JSON.parse(message);
envelope = JSON.parse(message) as GatewayEnvelope;
} catch (err) {
logger.error({ channel, err }, "Failed to parse Redis message as JSON");
return;
@@ -49,67 +43,10 @@ function handleSubscriptionMessage(channel: string, message: string): void {
// We only want <actual payload>, not the full envelope.
const data = envelope.data !== undefined ? envelope.data : envelope;
// Voice PCM: decode base64 → binary broadcast instead of JSON
if (channel === DISCORD_VOICE_PCM) {
const pcmPayload = data as { userId?: string; pcm?: string };
if (pcmPayload?.pcm && pcmPayload?.userId) {
try {
const pcmBuffer = Buffer.from(pcmPayload.pcm, "base64");
// Prepend userId as 4-byte FNV-1a hash
const userIdHash = hashUserId(pcmPayload.userId);
const binary = Buffer.alloc(4 + pcmBuffer.length);
binary.writeUInt32LE(userIdHash, 0);
pcmBuffer.copy(binary, 4);
broadcastBinary(binary);
return;
} catch {
// fallback to JSON broadcast on error
}
}
}
// Aggregate live-voice state authoritatively BEFORE broadcasting.
// Every browser hears the same `voice_active_user` deltas, so the backend
// can maintain the single shared snapshot for late-joining clients.
if (channel === DISCORD_VOICE_ACTIVE_USER) {
const speaker = data as {
userId?: string;
username?: string;
avatar?: string | null;
speaking?: boolean;
};
if (speaker?.userId) {
recordSpeaker({
userId: speaker.userId,
username: speaker.username,
avatar: speaker.avatar,
speaking: Boolean(speaker.speaking),
});
}
}
// When the gateway stops voice recording (disconnects from voice channel),
// clear all speakers from the authoritative snapshot so frontends don't
// show ghost participants.
if (channel === DISCORD_VOICE_STOPPED) {
clearAllSpeakers();
logger.info("Voice recording stopped — cleared all live speakers");
}
logger.debug({ channel, eventType }, "Broadcasting Redis event");
broadcastEvent(eventType, data);
}
/** Simple 32-bit FNV-1a hash for userId → 4-byte identifier */
function hashUserId(userId: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < userId.length; i++) {
hash ^= userId.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
export async function startRedisBridge(): Promise<void> {
if (!config.REDIS_URL) {
logger.info("Redis not configured, skipping Redis bridge");
+36 -168
View File
@@ -1,9 +1,8 @@
import type { IncomingMessage, Server } from "node:http";
import type { Duplex } from "node:stream";
import { WebSocket, WebSocketServer } from "ws";
import type { MessageQuery } from "../modules/messages/messages.schema.js";
import { messagesService } from "../modules/messages/messages.service.js";
import { config } from "../shared/config/index.js";
import { BACKEND_COMMAND, BACKEND_VOICE_TRANSMIT } from "../shared/index.js";
import { createChildLogger } from "../shared/logger/index.js";
import { setBroadcastFunctions } from "./broadcast.js";
@@ -17,11 +16,17 @@ interface BroadcastEvent {
interface JsonMessage {
type: string;
buffer?: string;
command?: string;
payload?: Record<string, unknown>;
}
/** Payload accepted by the `stream_messages` JSON command. */
interface StreamMessagesPayload {
guildId?: string;
channelId?: string;
cursor?: string;
limit?: number;
}
// Track the active WebSocket server for lifecycle management
let _wss: WebSocketServer | null = null;
@@ -54,36 +59,6 @@ async function sendInitialStates(ws: WebSocket): Promise<void> {
} catch (err) {
logger.warn({ err }, "Failed to send initial ui_state");
}
// Send initial media state
try {
const { getStatus } = await import("../modules/media/media.service.js");
const mediaState = await getStatus();
ws.send(
JSON.stringify({
type: "media_state",
state: mediaState,
}),
);
} catch (err) {
logger.warn({ err }, "Failed to send initial media_state");
}
// Send initial live-voice snapshot (shared authoritative state — a browser
// joining mid-call sees the same speakers as everyone else, not an empty DB).
try {
const { getActiveSpeakers } = await import(
"../modules/voice/live-speaker.js"
);
ws.send(
JSON.stringify({
type: "voice_state",
state: { activeSpeakers: getActiveSpeakers() },
}),
);
} catch (err) {
logger.warn({ err }, "Failed to send initial voice_state");
}
}
export function closeWebSocketServer(): void {
@@ -94,9 +69,7 @@ export function closeWebSocketServer(): void {
}
export function createWebSocketServer(server: Server): WebSocketServer {
// Separate tracking: gateway sends PCM → forwarded to frontend only
const frontendClients = new Set<WebSocket>();
const gatewayClients = new Set<WebSocket>();
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: true });
_wss = wss;
@@ -115,43 +88,9 @@ export function createWebSocketServer(server: Server): WebSocketServer {
// Map-based dispatcher for JSON WebSocket message types
const jsonHandlers = new Map<string, MessageHandler>();
jsonHandlers.set("voice_transmit", async (_ws, message) => {
if (!message.buffer) return;
const { getCommandPublisher } = await import("../shared/redis/index.js");
const publisher = getCommandPublisher();
await publisher.publish(
BACKEND_VOICE_TRANSMIT,
JSON.stringify({ type: "pcm", buffer: message.buffer }),
);
});
jsonHandlers.set("voice_command", async (_ws, message) => {
if (!message.command) return;
const { getCommandPublisher } = await import("../shared/redis/index.js");
const publisher = getCommandPublisher();
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
await publisher.publish(
BACKEND_COMMAND,
JSON.stringify({
id: commandId,
type: message.command,
payload: message.payload ?? {},
replyChannel: `reply:${commandId}`,
}),
);
});
// Stream historical messages one-by-one over WS (no 50-row batch).
// The frontend requests it once per channel switch; the backend emits one
// `message_snapshot` frame per message so the UI renders progressively.
jsonHandlers.set("stream_messages", async (ws, message) => {
if (ws.readyState !== WebSocket.OPEN) return;
const payload = (message.payload ?? {}) as {
guildId?: string;
channelId?: string;
cursor?: string;
limit?: number;
};
const payload = (message.payload ?? {}) as StreamMessagesPayload;
const guildId = payload.guildId;
const channelId = payload.channelId;
if (!guildId && !channelId) {
@@ -162,6 +101,17 @@ export function createWebSocketServer(server: Server): WebSocketServer {
const pageSize = 50; // internal DB page size; still emitted one frame at a time
const maxFrames = Math.min(payload.limit ?? 200, 500);
/** Send the end-of-stream frame, reporting sent count + next cursor. */
function sendEnd(data: Record<string, unknown>): void {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(
JSON.stringify({
type: "message_snapshot_end",
data,
}),
);
}
let sent = 0;
let nextCursor: string | null = null;
try {
@@ -170,7 +120,7 @@ export function createWebSocketServer(server: Server): WebSocketServer {
guildId,
channelId,
cursor: payload.cursor,
} as never,
} as MessageQuery,
pageSize,
)) {
if (ws.readyState !== WebSocket.OPEN) break;
@@ -187,95 +137,22 @@ export function createWebSocketServer(server: Server): WebSocketServer {
sent++;
if (sent >= maxFrames) break;
}
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "message_snapshot_end",
data: { sent, nextCursor },
}),
);
}
sendEnd({ sent, nextCursor });
} catch (err) {
logger.error({ err }, "stream_messages failed");
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "message_snapshot_end",
data: { sent, nextCursor, error: true },
}),
);
}
sendEnd({ sent, nextCursor, error: true });
}
});
wss.on("connection", (ws: WebSocket, req) => {
// Parse auth token from query string
const rawUrl = req.url ?? "/";
let isGateway = false;
try {
const url = new URL(rawUrl, "http://localhost");
const token = url.searchParams.get("token");
isGateway =
token !== null &&
config.BACKEND_WS_TOKEN !== "" &&
token === config.BACKEND_WS_TOKEN;
} catch {
// Malformed URL — treat as frontend
}
if (isGateway) {
gatewayClients.add(ws);
logger.info("Discord gateway WebSocket client authenticated");
// Gateway doesn't need initial states
} else {
frontendClients.add(ws);
logger.info(`Frontend client connected (${frontendClients.size} total)`);
// Send initial states (user, ui, media) — fire-and-forget
sendInitialStates(ws).catch((err) =>
logger.error({ err }, "sendInitialStates failed"),
);
}
wss.on("connection", (ws: WebSocket) => {
frontendClients.add(ws);
logger.info(`Frontend client connected (${frontendClients.size} total)`);
// Send initial states (user, ui) — fire-and-forget
sendInitialStates(ws).catch((err) =>
logger.error({ err }, "sendInitialStates failed"),
);
ws.on("message", (data: Buffer) => {
// Gateway PCM forward — broadcast raw binary to frontend clients only
if (isGateway && Buffer.isBuffer(data)) {
broadcastBinary(data);
return;
}
// Handle binary PCM from browser (FE→Discord transmit)
// Format: 4-byte magic "PCM\0" + raw PCM Int16 LE
if (
Buffer.isBuffer(data) &&
data.length > 4 &&
data[0] === 0x50 && // 'P'
data[1] === 0x43 && // 'C'
data[2] === 0x4d && // 'M'
data[3] === 0x00 // '\0'
) {
const pcmBuffer = data.subarray(4);
const base64 = pcmBuffer.toString("base64");
import("../shared/redis/index.js").then(({ getCommandPublisher }) => {
const publisher = getCommandPublisher();
publisher
.publish(
BACKEND_VOICE_TRANSMIT,
JSON.stringify({
type: "pcm",
buffer: base64,
}),
)
.catch((err: Error) => {
logger.error(
{ err },
"Failed to publish voice transmit to Redis",
);
});
});
return;
}
// Handle JSON messages from browser
if (
typeof data === "string" ||
@@ -296,24 +173,15 @@ export function createWebSocketServer(server: Server): WebSocketServer {
});
ws.on("close", () => {
if (isGateway) {
gatewayClients.delete(ws);
logger.info("Discord gateway WebSocket disconnected");
} else {
frontendClients.delete(ws);
logger.info(
`Frontend client disconnected (${frontendClients.size} total)`,
);
}
frontendClients.delete(ws);
logger.info(
`Frontend client disconnected (${frontendClients.size} total)`,
);
});
ws.on("error", (err: Error) => {
logger.error({ err }, "WebSocket client error");
if (isGateway) {
gatewayClients.delete(ws);
} else {
frontendClients.delete(ws);
}
frontendClients.delete(ws);
});
});
+1 -25
View File
@@ -36,9 +36,6 @@ src/
├── modules/
│ ├── ai-moderation/ # LLM moderation pipeline (largest module)
│ ├── message-capture/ # Discord event listeners + DB store
│ ├── voice-recording/ # Voice connect + Opus→OGG recording
│ │ └── recorder/ # decoder, segment, session, uploader, oggCrc
│ ├── voice-pcm-ws/ # Real-time PCM → backend WebSocket
│ ├── attachment-upload/ # Download + sharp resize + upload
│ ├── event-broadcaster/ # Redis pub/sub publisher
│ ├── command-handler/ # Backend→gateway Redis commands
@@ -76,24 +73,6 @@ textBatchProcessor.ts mediaBatchProcessor.ts llmClient.ts
- Piscina: text pool (4 threads) + media pool (2 threads)
- **Each worker thread has its own pg Pool** (min 0, grows to `POSTGRES_POOL_MAX`)
## Voice recording pipeline
```
receiver.speaking "start" → speakingHandler(userId)
→ collectUserMetadata → receiver.subscribe → PacketFilter → oggPacketStream
→ SegmentManager.open → OggLogicalBitstream → .ogg file
→ data: rotateIfNeeded + decoder.write
→ end: finalizeSegment → upload + transcribe
```
Key files:
- `voiceController.ts` — connect/disconnect/list
- `recorder.ts` — orchestration
- `recorder/segment.ts` — segment rotation
- `recorder/sessionRecording.ts` — session management
- `recorder/uploader.ts` — upload to storage
- `voiceTranscriber.ts` — Whisper transcription (if enabled)
## Module: message-capture
- `messageCapture.ts` — Discord event listeners (messageCreate/Update/Delete)
@@ -107,7 +86,7 @@ Key files:
See `src/shared/redis-channels.ts` for canonical names. Examples:
```
discord:message:created, discord:voice:active_user, discord:attachment:uploaded
discord:message:created, discord:moderation:action, discord:attachment:uploaded
```
## Config (env vars)
@@ -120,7 +99,6 @@ All validated via Zod in `shared/config/index.ts`. Critical:
- `REDIS_URL` — pub/sub to backend
- `AI_ANALYSIS_ENABLED` — master toggle for AI moderation
- `AI_LLM_BASE_URL` / `AI_LLM_API_KEY` — LLM router
- `AI_VOICE_TRANSCRIPTION_ENABLED` — toggle Whisper transcription
- `PISCINA_MAX_THREADS` / `PISCINA_MEDIA_MAX_THREADS` — worker pool sizing
## Concurrency model
@@ -143,8 +121,6 @@ All validated via Zod in `shared/config/index.ts`. Critical:
- **Piscina pool isolation**: worker threads are NOT the main thread. Cannot
share state via module-level variables. Use DB or Redis for cross-thread state.
- **AfterSilence race**: `@discordjs/voice` AfterSilence can fail to emit "end"
on disconnect. Always have a watchdog/timeout.
- **Cache eviction**: LRU caches (user metadata, term glossary) evict at max size.
Don't assume cache hit after eviction.
- **Circuit breaker**: per-conversation CB opens after repeated failures.
+5 -9
View File
@@ -1,7 +1,7 @@
# Discord Gateway — Architecture
Pure event-driven microservice (no HTTP server). Captures Discord
messages/voice/attachments/reactions/threads/presence, runs LLM-based AI
messages/attachments/reactions/threads/presence, runs LLM-based AI
moderation, and publishes everything to Redis pub/sub for the backend to
consume. The backend serves the HTTP/WS API to the frontend.
@@ -24,9 +24,9 @@ services/discord-gateway/
│ │ ├── config/ # Zod-validated env (index.ts = schema+loader)
│ │ ├── database/ # Drizzle ORM + pg Pool + migrations
│ │ │ ├── init.ts drizzle.ts pool.ts migrate.ts migrateCli.ts
│ │ │ └── schema/ # messages, cache, voice, analytics, meta
│ │ │ └── schema/ # messages, cache, meta, analytics
│ │ ├── logger/ # pino wrapper + createChildLogger()
│ │ ├── errors/ # AppError / ConfigError / AudioError ...
│ │ ├── errors/ # AppError / ConfigError ...
│ │ ├── utils/ # retry, pagination
│ │ ├── discord/clientOptions.ts # discord.js-selfbot-v13 client options
│ │ ├── uploader.ts # Shared attachment upload helper
@@ -35,9 +35,6 @@ services/discord-gateway/
│ └── modules/
│ ├── message-capture/ # Discord event listeners + DB store
│ ├── ai-moderation/ # LLM moderation pipeline (see below)
│ ├── voice-recording/ # Voice connect + Opus→OGG recording
│ │ └── recorder/ # decoder, segment, session, uploader, oggCrc
│ ├── voice-pcm-ws/ # Real-time PCM → backend WebSocket (bypasses Redis)
│ ├── attachment-upload/ # Download + (sharp) resize + upload
│ ├── event-broadcaster/ # RedisEventPublisher + EventBroadcaster
│ ├── command-handler/ # Redis-subscribed backend→gateway commands
@@ -102,7 +99,6 @@ grows on demand up to `POSTGRES_POOL_MAX`.
`discord:message:{created,updated,deleted,analyzed}`,
`discord:attachment:{created,uploaded}`,
`discord:voice:{started,stopped,uploaded,active_user,pcm,analyzed}`,
`discord:analysis:queue_status`,
`discord:reaction:{added,removed}`,
`discord:thread:{created,deleted,updated}`,
@@ -124,8 +120,8 @@ See `src/shared/redis-channels.ts` for the canonical names.
`SIGINT`/`SIGTERM` (and uncaught transient stream errors: EPIPE / ECONNRESET /
ERR_STREAM_DESTROYED / ERR_STREAM_WRITE_AFTER_END are treated as non-fatal):
stop metrics → stop muxer → disconnect voice → close PCM WS → close Redis →
close command handler → close DB → destroy client → exit.
stop metrics → close event broadcaster (Redis) → close command handler →
close DB → destroy client → exit.
## Observability
+1 -8
View File
@@ -18,8 +18,6 @@ services/discord-gateway/
│ └── modules/
│ ├── message-capture/ # Discord listeners + DB store + metadata
│ ├── ai-moderation/ # LLM moderation pipeline (largest module)
│ ├── voice-recording/ # Voice connect + Opus→OGG recording (+ recorder/)
│ ├── voice-pcm-ws/ # Real-time PCM → backend WebSocket
│ ├── attachment-upload/ # Download + sharp resize + upload
│ ├── event-broadcaster/ # RedisEventPublisher + EventBroadcaster
│ ├── command-handler/ # Backend→gateway Redis commands
@@ -51,11 +49,6 @@ semantic Qdrant → LLM), `textBatchProcessor.ts` / `mediaBatchProcessor.ts`
`embeddingClient.ts` + `qdrantClient.ts` (semantic cache), plus
`channelCultureStore.ts` / `userProfileStore.ts`.
### voice-recording
`voiceController.ts` (connect/disconnect/list) + `recorder.ts` (orchestration)
+ `recorder/` (decoder, segment, session, uploader, oggCrc). Publishes
`discord:voice:*` events. Real-time audio also streamed via `voice-pcm-ws`.
### attachment-upload
`attachmentUploader.ts` (download → upload to storage) + `imageResizer.ts`
(sharp resize). Emits `discord:attachment:*`.
@@ -72,7 +65,7 @@ per scrape; live pipeline gauges registered in `bootstrap.ts`.
- **config** — Zod schema in `shared/config/index.ts` (single source of truth).
- **database** — Drizzle ORM over `pg`; pool `min:0` (`shared/config`).
- **logger** — `pino` wrapper, `createChildLogger()` for context loggers.
- **errors** — `AppError` hierarchy (`ConfigError`, `AudioError`, …).
- **errors** — `AppError` hierarchy (`ConfigError`, …).
## Notes
- No HTTP server (other than the metrics endpoint). Pure event-driven.
+29 -73
View File
@@ -19,7 +19,6 @@ services/discord-gateway/
│ │ │ ├── schema.ts # Drizzle ORM schema
│ │ │ ├── drizzle.ts # PostgreSQL connection
│ │ │ ├── migrate.ts # Migration runner
│ │ │ └── voiceRecordingRepo.ts
│ │ ├── errors/
│ │ │ └── errors.ts # Custom error classes
│ │ ├── logger/
@@ -43,17 +42,6 @@ services/discord-gateway/
│ │ │ ├── indonesianTextNormalizer.ts # Service: Text normalization
│ │ │ ├── moderationPrompt.ts # Service: Prompt generation
│ │ │ └── index.ts # Module exports
│ │ ├── voice-recording/ # Controller-Service-Repository
│ │ │ ├── voiceController.ts # Controller: Voice connection mgmt
│ │ │ ├── recorder.ts # Service: Recording orchestration
│ │ │ ├── recorder/ # Sub-services
│ │ │ │ ├── audioStream.ts # Audio stream subscription
│ │ │ │ ├── decoder.ts # Opus decoding
│ │ │ │ ├── segment.ts # OGG segment rotation
│ │ │ │ ├── metadata.ts # Segment metadata
│ │ │ │ ├── sessionRecording.ts # Session management
│ │ │ │ └── uploader.ts # Segment upload
│ │ │ └── index.ts # Module exports
│ │ ├── attachment-upload/ # Controller-Service-Repository
│ │ │ ├── attachmentUploader.ts # Service: Upload orchestration
│ │ │ ├── imageResizer.ts # Service: Image resizing
@@ -85,11 +73,6 @@ Each feature module follows **Controller-Service-Repository** pattern:
- **Service** (`aiAnalysisWorker.ts`): Worker pool management
- **Service** (`indonesianTextNormalizer.ts`): Text preprocessing
**Voice Recording Module**:
- **Controller** (`voiceController.ts`): Voice channel connection management
- **Service** (`recorder.ts`): Recording orchestration
- **Sub-services** (`recorder/*`): Audio stream, decoding, segmentation, upload
**Attachment Upload Module**:
- **Service** (`attachmentUploader.ts`): Upload orchestration
- **Service** (`imageResizer.ts`): Image processing
@@ -107,9 +90,6 @@ Discord Events → Discord Gateway Service → Redis Pub/Sub → Backend Service
- discord:message:analyzed
- discord:attachment:created
- discord:attachment:uploaded
- discord:voice:started
- discord:voice:stopped
- discord:voice:uploaded
- discord:analysis:queue_status
```
@@ -146,20 +126,6 @@ Centralized, reusable components:
5. `eventBroadcaster.messageAnalyzed()` publishes results
6. Backend service receives and updates UI
### Voice Recording
1. `voiceController.connect()` joins voice channel
2. `recorder.ts` subscribes to user audio streams
3. For each speaking user:
- `audioStream.ts` subscribes to Opus packets
- `decoder.ts` decodes Opus to PCM
- `segment.ts` rotates OGG files (5s default)
- `metadata.ts` collects user info
4. On silence (3s):
- `sessionRecording.ts` finalizes segment
- `uploader.ts` uploads to storage
- `eventBroadcaster.voiceRecordingUploaded()` publishes
5. Backend service indexes recording
### Attachment Upload
1. `messageCapture.ts` detects attachments
2. `attachmentUploader.ts` downloads from Discord
@@ -194,21 +160,16 @@ Centralized, reusable components:
On SIGINT/SIGTERM/uncaughtException/unhandledRejection:
1. Close PostgreSQL connection
2. Disconnect from voice channels
3. Close Redis connection
4. Destroy Discord client
5. Exit process (code 0 for clean, 1 for error)
2. Close Redis connection
3. Destroy Discord client
4. Exit process (code 0 for clean, 1 for error)
## Dependencies
**Core Discord**:
- `discord.js-selfbot-v13` — Discord client (selfbot variant)
- `@discordjs/voice` — Voice connection management
- `@discordjs/opus` — Native Opus codec
**Audio Processing**:
- `prism-media` — Opus encoding/decoding
- `opusscript` — Opus fallback for Node v26+
**Media Processing**:
- `sharp` — Image resizing
**Data & Config**:
@@ -269,7 +230,6 @@ On SIGINT/SIGTERM/uncaughtException/unhandledRejection:
### Modules (28 files)
- `src/modules/message-capture/` (5 files)
- `src/modules/ai-moderation/` (6 files)
- `src/modules/voice-recording/` (9 files)
- `src/modules/attachment-upload/` (3 files)
- `src/modules/event-broadcaster/` (3 files)
@@ -289,7 +249,6 @@ On SIGINT/SIGTERM/uncaughtException/unhandledRejection:
✅ Shared infrastructure migrated
✅ Message capture module migrated
✅ AI moderation module migrated
✅ Voice recording module migrated
✅ Attachment upload module migrated
✅ Event broadcaster module created (Redis pub/sub)
✅ Bootstrap and entry point created
@@ -304,36 +263,33 @@ On SIGINT/SIGTERM/uncaughtException/unhandledRejection:
```
┌─────────────────────────────────────────────────────────────────┐
│ Discord Gateway Service │
│ Discord Gateway Service │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐ │
│ │ Message Capture │ │ AI Moderation │ │ Voice Record │ │
│ │ (Controller) │ │ (Controller) │ │ (Controller) │ │
│ └────────┬─────────┘ └────────┬─────────┘ └──────┬───────┘ │
│ │ │ │ │
│ ├─────────────────────┼───────────────────┤ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Event Broadcaster (Redis Pub/Sub) │ │
│ │ - discord:message:created │ │
│ │ - discord:message:updated │ │
│ │ - discord:message:deleted │ │
│ │ - discord:message:analyzed │ │
│ │ - discord:attachment:created │ │
│ │ - discord:attachment:uploaded │ │
│ │ - discord:voice:started │ │
│ │ - discord:voice:stopped │ │
│ │ - discord:voice:uploaded │ │
│ │ - discord:analysis:queue_status │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
└───────────┼────────────────────────────────────────────────────┘
│
│ Redis Pub/Sub
│
▼
│ ┌────────────────────────────┐ ┌────────────────────────────┐ │
│ │ Message Capture │ │ AI Moderation │ │
│ │ (Controller) │ │ (Controller) │ │
│ └──────────────┬─────────────┘ └──────────────┬─────────────┘ │
│ │ │ │
│ ├───────────────────────────────┤ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Event Broadcaster (Redis Pub/Sub) │ │
│ │ - discord:message:created │ │
│ │ - discord:message:updated │ │
│ │ - discord:message:deleted │ │
│ │ - discord:message:analyzed │ │
│ │ - discord:attachment:created │ │
│ │ - discord:attachment:uploaded │ │
│ │ - discord:analysis:queue_status │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
└────────────────────────────────┼────────────────────────────────┘
│
│ Redis Pub/Sub
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Backend Service │
│ (Subscribes to events, serves HTTP API, manages WebSocket) │
@@ -1 +0,0 @@
ALTER TABLE "voice_recordings" ADD COLUMN "transcription" text;
@@ -1,19 +0,0 @@
-- Voice auto-reconnect: persist desired voice state per guild.
-- The gateway rejoins the same channel after restart/reboot and after an
-- unexpected voice drop (kick / server move / voice server restart).
-- Idempotent: skips if the table already exists.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'voice_auto_reconnect'
) THEN
CREATE TABLE "voice_auto_reconnect" (
"guild_id" text PRIMARY KEY NOT NULL,
"channel_id" text NOT NULL,
"channel_name" text,
"connected_at" bigint NOT NULL,
"updated_at" bigint NOT NULL
);
END IF;
END $$;
@@ -79,13 +79,6 @@
"tag": "0010_add_reactions_and_edit_history",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1785600000000,
"tag": "0011_add_voice_transcription",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
@@ -141,13 +134,6 @@
"when": 1787830000000,
"tag": "0019_rename_server_name_to_server_nick",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1787860000000,
"tag": "0020_add_voice_auto_reconnect",
"breakpoints": true
}
]
}
+1 -7
View File
@@ -1,12 +1,11 @@
{
"name": "@bete/discord-gateway",
"version": "1.0.0",
"description": "Discord Gateway service - handles message capture, voice recording, and AI moderation",
"description": "Discord Gateway service - handles message capture and AI moderation",
"type": "module",
"main": "dist/index.js",
"pnpm": {
"onlyBuiltDependencies": [
"@discordjs/opus",
"esbuild",
"sharp"
]
@@ -24,16 +23,12 @@
"test:e2e:live": "bash scripts/run-llm-e2e.sh"
},
"dependencies": {
"@discordjs/opus": "^0.10.0",
"@discordjs/voice": "file:vendor/discord-voice-fork",
"@snazzah/davey": "^0.1.11",
"@typesafe-ai/sdk": "^0.6.0",
"axios": "^1.20.0",
"discord.js-selfbot-v13": "^3.7.1",
"dotenv": "^18.0.0",
"drizzle-orm": "^0.45.2",
"ioredis": "^6.0.0",
"libsodium-wrappers": "^0.8.4",
"lru-cache": "^11.5.3",
"openai": "^7.20.0",
"opusscript": "^0.1.1",
@@ -42,7 +37,6 @@
"pg": "^8.23.0",
"pino": "^10.3.1",
"piscina": "^5.3.2",
"prism-media": "2.0.0-alpha.0",
"sharp": "^0.35.4",
"tiktoken": "^1.0.22",
"ws": "^8.21.3",
+74 -277
View File
@@ -8,15 +8,6 @@ importers:
.:
dependencies:
'@discordjs/opus':
specifier: ^0.10.0
version: 0.10.0(supports-color@7.2.0)
'@discordjs/voice':
specifier: file:vendor/discord-voice-fork
version: file:vendor/discord-voice-fork(@discordjs/opus@0.10.0(supports-color@7.2.0))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(opusscript@0.1.1)
'@snazzah/davey':
specifier: ^0.1.11
version: 0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
'@typesafe-ai/sdk':
specifier: ^0.6.0
version: 0.6.0
@@ -25,7 +16,7 @@ importers:
version: 1.20.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)
discord.js-selfbot-v13:
specifier: ^3.7.1
version: 3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1)(supports-color@7.2.0)
version: 3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(supports-color@7.2.0)
dotenv:
specifier: ^18.0.0
version: 18.0.1
@@ -35,9 +26,6 @@ importers:
ioredis:
specifier: ^6.0.0
version: 6.0.0(supports-color@7.2.0)
libsodium-wrappers:
specifier: ^0.8.4
version: 0.8.4
lru-cache:
specifier: ^11.5.3
version: 11.5.3
@@ -62,9 +50,6 @@ importers:
piscina:
specifier: ^5.3.2
version: 5.3.2
prism-media:
specifier: 2.0.0-alpha.0
version: 2.0.0-alpha.0
sharp:
specifier: ^0.35.4
version: 0.35.4(@types/node@26.4.0)
@@ -186,19 +171,12 @@ packages:
resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==}
engines: {node: '>=18'}
'@discordjs/voice@file:vendor/discord-voice-fork':
resolution: {directory: vendor/discord-voice-fork, type: directory}
engines: {node: '>=22.12.0'}
'@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
'@emnapi/core@1.11.1':
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
'@emnapi/core@1.11.3':
resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==}
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
@@ -208,9 +186,6 @@ packages:
'@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
'@emnapi/wasi-threads@1.2.3':
resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==}
'@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.hirok.io'
@@ -1088,97 +1063,6 @@ packages:
resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==}
engines: {node: '>=v16'}
'@snazzah/davey-android-arm-eabi@0.1.12':
resolution: {integrity: sha512-6VC/an+Sx5dI5skb+90rYcIB1jhm48Rl0nDaw0UNT4bz1rMjpVfmmZqeocYXMq96IdbBMlE6OTKGcBm2C3gkQg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
'@snazzah/davey-android-arm64@0.1.12':
resolution: {integrity: sha512-0Bwd03/JsTFhlPhF4q/LW0RxSzntFpQdhz+TBdFljYSg8IEyA38saPJeTNjpIgDfhAumPzvhCdfS6O5qT8yXDw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@snazzah/davey-darwin-arm64@0.1.12':
resolution: {integrity: sha512-lKMV6ITi9BQLt0fx/pAT7M8xcojVK7bryVJGdaW3bq8gABFslS3ti/KzrWabQvhpEV71FZe5mV0UcKHFVaTsZw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@snazzah/davey-darwin-x64@0.1.12':
resolution: {integrity: sha512-vXXc/eW/e3TQeb7VsdtrPqs3/22j0aSqiP1ZXmZtDjQRBwgSxwItWYa6sh5MELP2EHB2igNlGzB6Hc0XlbGi4g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@snazzah/davey-freebsd-x64@0.1.12':
resolution: {integrity: sha512-G1gas5HrC4Xp3mRY0+OeAqXS6fG2tRgBEc8gQh69Hw4YK9RV9mzQKcmoKMkBM72U1+2C+2u57dolnKKzwozByQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@snazzah/davey-linux-arm-gnueabihf@0.1.12':
resolution: {integrity: sha512-97Fujh82r2Ll7dPZeNuoZ3yKfsqycf3c93OWXOo/ThNL/18Onl2Ht4SIvpX6VHhfeS9bDpHJ1lHCaz1b/i5ocw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@snazzah/davey-linux-arm64-gnu@0.1.12':
resolution: {integrity: sha512-FWyAOv52cHKDM4BOsmImKKogHFvqNFoXmZcicNJbX3XpVl8Mas88ZoXQ+IA5V+qc9pNtAl1MbWwEZ9JrqAQtbg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@snazzah/davey-linux-arm64-musl@0.1.12':
resolution: {integrity: sha512-ptRbLSQxtV6EjXppS5z7qaPDI0NRKhrkJYsTlAjEghmOvlAObozSCYYnMO6nbkt6Ab3+lWqyahClzcRNcD2ouw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@snazzah/davey-linux-x64-gnu@0.1.12':
resolution: {integrity: sha512-w86fZvhJn0ErOoAQHt2UbQ95V/cgwvfvQ4GlTPQLCzt58nn+rLlXLgPn90qYlSQrZxFW38rKXwqVOMbm9p+pwQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@snazzah/davey-linux-x64-musl@0.1.12':
resolution: {integrity: sha512-LLNnO+hfG41ymeI+O1YHo5/0h3aKaetUNLdkBwpdJsjoyKMXZaeCnB+aHNkkupJCMPmWS0g6iPMCHUOqZSBcTg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@snazzah/davey-wasm32-wasi@0.1.12':
resolution: {integrity: sha512-MPKFuqYVkDFXheR7qmtEY4FWxQ/ADfgsCojQWHi13sibUqCTR9q2F1LqNn2i9IVh3sh1sxeg87fdFMCH63pl7g==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@snazzah/davey-win32-arm64-msvc@0.1.12':
resolution: {integrity: sha512-Uf4OYHyfbXpzyaOqIV8/h6kv166Qni5+Bxmc1E/ov4uhhKO8qXbOky8zbVOzu0U4cv5ll3s4IQ8jDAJkx/K75Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@snazzah/davey-win32-ia32-msvc@0.1.12':
resolution: {integrity: sha512-nRVbKTsb2ldcPI8D4BDA7P/UeiMEMvR+wYuUMp7H1pRD/3dF2hKo+MzUU+Pn78EhuYA3CWHItiAcS/GsPld67A==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@snazzah/davey-win32-x64-msvc@0.1.12':
resolution: {integrity: sha512-AgUA3itPDVkxQq7RIkgE1thCiWePwWjyfOZefBBxGIlMWpRUOSwF4vY9kNLnrScyJcFULdR+Zm8PwiwV8/RKnw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@snazzah/davey@0.1.12':
resolution: {integrity: sha512-V+NlX5931RwVamZhhEfZekMdcvXDKdMAmHW1AuGaykVQsNyBOq3bpmGpoKRBDCYgFWKIufJ0Dcg3m4cYhvUy6g==}
engines: {node: '>= 10'}
'@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
@@ -1601,9 +1485,6 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
duplex-child-process@1.0.1:
resolution: {integrity: sha512-tWbt4tyioDjyK5nh+qicbdvBvNjSXsTUF5zKUwSauuKPg1mokjwn/HezwfvWhh6hXoLdgetY+ZlzU/sMwUMJkg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -1772,12 +1653,6 @@ packages:
resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==}
engines: {node: '>=16'}
libsodium-wrappers@0.8.4:
resolution: {integrity: sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==}
libsodium@0.8.4:
resolution: {integrity: sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==}
lightningcss-android-arm64@1.33.0:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'}
@@ -1978,9 +1853,6 @@ packages:
zod:
optional: true
opusscript@0.1.1:
resolution: {integrity: sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==}
otplib@12.0.1:
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
@@ -2108,9 +1980,6 @@ packages:
opusscript:
optional: true
prism-media@2.0.0-alpha.0:
resolution: {integrity: sha512-QL9rnO4xo0grgj7ptsA+AzSCYLirGWM4+ZcyboFmbkYHSgaXIESzHq/SXNizz2iHIfuM2og0cPhmSnTVMeFjKg==}
process-warning@5.1.0:
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
@@ -2539,6 +2408,7 @@ snapshots:
transitivePeerDependencies:
- encoding
- supports-color
optional: true
'@discordjs/opus@0.10.0(supports-color@7.2.0)':
dependencies:
@@ -2547,29 +2417,12 @@ snapshots:
transitivePeerDependencies:
- encoding
- supports-color
optional: true
'@discordjs/util@1.2.0':
dependencies:
discord-api-types: 0.38.52
'@discordjs/voice@file:vendor/discord-voice-fork(@discordjs/opus@0.10.0(supports-color@7.2.0))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(opusscript@0.1.1)':
dependencies:
'@snazzah/davey': 0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
'@types/ws': 8.18.1
discord-api-types: 0.38.52
prism-media: 1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1)
tslib: 2.8.1
ws: 8.21.3
transitivePeerDependencies:
- '@discordjs/opus'
- '@emnapi/core'
- '@emnapi/runtime'
- bufferutil
- ffmpeg-static
- node-opus
- opusscript
- utf-8-validate
'@drizzle-team/brocli@0.10.2': {}
'@emnapi/core@1.11.1':
@@ -2578,12 +2431,6 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/core@1.11.3':
dependencies:
'@emnapi/wasi-threads': 1.2.3
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
@@ -2599,11 +2446,6 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.2.3':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild-kit/core-utils@3.3.2':
dependencies:
esbuild: 0.18.20
@@ -3032,13 +2874,6 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
'@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)':
dependencies:
'@emnapi/core': 1.11.3
'@emnapi/runtime': 1.11.3
'@tybys/wasm-util': 0.10.3
optional: true
'@otplib/core@12.0.1': {}
'@otplib/plugin-crypto@12.0.1':
@@ -3124,73 +2959,6 @@ snapshots:
fast-deep-equal: 3.1.3
lodash: 4.18.1
'@snazzah/davey-android-arm-eabi@0.1.12':
optional: true
'@snazzah/davey-android-arm64@0.1.12':
optional: true
'@snazzah/davey-darwin-arm64@0.1.12':
optional: true
'@snazzah/davey-darwin-x64@0.1.12':
optional: true
'@snazzah/davey-freebsd-x64@0.1.12':
optional: true
'@snazzah/davey-linux-arm-gnueabihf@0.1.12':
optional: true
'@snazzah/davey-linux-arm64-gnu@0.1.12':
optional: true
'@snazzah/davey-linux-arm64-musl@0.1.12':
optional: true
'@snazzah/davey-linux-x64-gnu@0.1.12':
optional: true
'@snazzah/davey-linux-x64-musl@0.1.12':
optional: true
'@snazzah/davey-wasm32-wasi@0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)':
dependencies:
'@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
'@snazzah/davey-win32-arm64-msvc@0.1.12':
optional: true
'@snazzah/davey-win32-ia32-msvc@0.1.12':
optional: true
'@snazzah/davey-win32-x64-msvc@0.1.12':
optional: true
'@snazzah/davey@0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)':
optionalDependencies:
'@snazzah/davey-android-arm-eabi': 0.1.12
'@snazzah/davey-android-arm64': 0.1.12
'@snazzah/davey-darwin-arm64': 0.1.12
'@snazzah/davey-darwin-x64': 0.1.12
'@snazzah/davey-freebsd-x64': 0.1.12
'@snazzah/davey-linux-arm-gnueabihf': 0.1.12
'@snazzah/davey-linux-arm64-gnu': 0.1.12
'@snazzah/davey-linux-arm64-musl': 0.1.12
'@snazzah/davey-linux-x64-gnu': 0.1.12
'@snazzah/davey-linux-x64-musl': 0.1.12
'@snazzah/davey-wasm32-wasi': 0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
'@snazzah/davey-win32-arm64-msvc': 0.1.12
'@snazzah/davey-win32-ia32-msvc': 0.1.12
'@snazzah/davey-win32-x64-msvc': 0.1.12
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
'@tybys/wasm-util@0.10.3':
dependencies:
tslib: 2.8.1
@@ -3298,7 +3066,8 @@ snapshots:
'@vitest/spy@5.0.1': {}
abbrev@1.1.1: {}
abbrev@1.1.1:
optional: true
agent-base@6.0.2(supports-color@7.2.0):
dependencies:
@@ -3312,12 +3081,14 @@ snapshots:
dependencies:
color-convert: 2.0.1
aproba@2.1.0: {}
aproba@2.1.0:
optional: true
are-we-there-yet@2.0.0:
dependencies:
delegates: 1.0.0
readable-stream: 3.6.2
optional: true
assertion-error@2.0.1: {}
@@ -3335,7 +3106,8 @@ snapshots:
- debug
- supports-color
balanced-match@1.0.2: {}
balanced-match@1.0.2:
optional: true
base64-js@1.5.1: {}
@@ -3343,6 +3115,7 @@ snapshots:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
optional: true
buffer-from@1.1.2: {}
@@ -3365,7 +3138,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
chownr@2.0.0: {}
chownr@2.0.0:
optional: true
cliui@6.0.0:
dependencies:
@@ -3381,7 +3155,8 @@ snapshots:
color-name@1.1.4: {}
color-support@1.1.3: {}
color-support@1.1.3:
optional: true
combined-stream@1.0.8:
dependencies:
@@ -3389,9 +3164,11 @@ snapshots:
commander@14.0.3: {}
concat-map@0.0.1: {}
concat-map@0.0.1:
optional: true
console-control-strings@1.1.0: {}
console-control-strings@1.1.0:
optional: true
debug@4.4.0(supports-color@7.2.0):
dependencies:
@@ -3409,7 +3186,8 @@ snapshots:
delayed-stream@1.0.0: {}
delegates@1.0.0: {}
delegates@1.0.0:
optional: true
denque@2.1.0: {}
@@ -3419,7 +3197,7 @@ snapshots:
discord-api-types@0.38.52: {}
discord.js-selfbot-v13@3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1)(supports-color@7.2.0):
discord.js-selfbot-v13@3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
'@discordjs/builders': 1.14.1
'@discordjs/collection': 2.1.1
@@ -3429,7 +3207,7 @@ snapshots:
fetch-cookie: 3.2.0
find-process: 2.1.1
otplib: 12.0.1
prism-media: 1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1)
prism-media: 1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))
qrcode: 1.5.4
tough-cookie: 5.1.2
tree-kill: 1.2.2
@@ -3465,8 +3243,6 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
duplex-child-process@1.0.1: {}
emoji-regex@8.0.0: {}
es-define-property@1.0.1: {}
@@ -3612,8 +3388,10 @@ snapshots:
fs-minipass@2.1.0:
dependencies:
minipass: 3.3.6
optional: true
fs.realpath@1.0.0: {}
fs.realpath@1.0.0:
optional: true
fsevents@2.3.3:
optional: true
@@ -3631,6 +3409,7 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wide-align: 1.1.5
optional: true
get-caller-file@2.0.5: {}
@@ -3664,6 +3443,7 @@ snapshots:
minimatch: 3.1.5
once: 1.4.0
path-is-absolute: 1.0.1
optional: true
gopd@1.2.0: {}
@@ -3675,7 +3455,8 @@ snapshots:
dependencies:
has-symbols: 1.1.0
has-unicode@2.0.1: {}
has-unicode@2.0.1:
optional: true
hasown@2.0.4:
dependencies:
@@ -3694,8 +3475,10 @@ snapshots:
dependencies:
once: 1.4.0
wrappy: 1.0.2
optional: true
inherits@2.0.4: {}
inherits@2.0.4:
optional: true
ioredis@6.0.0(supports-color@7.2.0):
dependencies:
@@ -3712,12 +3495,6 @@ snapshots:
is-network-error@1.3.2: {}
libsodium-wrappers@0.8.4:
dependencies:
libsodium: 0.8.4
libsodium@0.8.4: {}
lightningcss-android-arm64@1.33.0:
optional: true
@@ -3784,6 +3561,7 @@ snapshots:
make-dir@3.1.0:
dependencies:
semver: 6.3.1
optional: true
math-intrinsics@1.1.0: {}
@@ -3801,33 +3579,41 @@ snapshots:
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.18
optional: true
minipass@3.3.6:
dependencies:
yallist: 4.0.0
optional: true
minipass@5.0.0: {}
minipass@5.0.0:
optional: true
minizlib@2.1.2:
dependencies:
minipass: 3.3.6
yallist: 4.0.0
optional: true
mkdirp@1.0.4: {}
mkdirp@1.0.4:
optional: true
ms@2.1.3: {}
nanoid@3.3.16: {}
node-addon-api@8.9.0: {}
node-addon-api@8.9.0:
optional: true
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
optional: true
nopt@5.0.0:
dependencies:
abbrev: 1.1.1
optional: true
npmlog@5.0.1:
dependencies:
@@ -3835,8 +3621,10 @@ snapshots:
console-control-strings: 1.1.0
gauge: 3.0.2
set-blocking: 2.0.0
optional: true
object-assign@4.1.1: {}
object-assign@4.1.1:
optional: true
obug@2.1.4: {}
@@ -3845,6 +3633,7 @@ snapshots:
once@1.4.0:
dependencies:
wrappy: 1.0.2
optional: true
openai@7.20.0(undici@7.29.0)(ws@8.21.3)(zod@4.6.5):
optionalDependencies:
@@ -3852,8 +3641,6 @@ snapshots:
ws: 8.21.3
zod: 4.6.5
opusscript@0.1.1: {}
otplib@12.0.1:
dependencies:
'@otplib/core': 12.0.1
@@ -3880,7 +3667,8 @@ snapshots:
path-exists@4.0.0: {}
path-is-absolute@1.0.1: {}
path-is-absolute@1.0.1:
optional: true
pg-cloudflare@1.4.0:
optional: true
@@ -3963,14 +3751,9 @@ snapshots:
dependencies:
xtend: 4.0.2
prism-media@1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1):
prism-media@1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0)):
optionalDependencies:
'@discordjs/opus': 0.10.0(supports-color@7.2.0)
opusscript: 0.1.1
prism-media@2.0.0-alpha.0:
dependencies:
duplex-child-process: 1.0.1
process-warning@5.1.0: {}
@@ -3989,6 +3772,7 @@ snapshots:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
optional: true
real-require@0.2.0: {}
@@ -4005,6 +3789,7 @@ snapshots:
rimraf@3.0.2:
dependencies:
glob: 7.2.3
optional: true
rolldown@1.1.5:
dependencies:
@@ -4027,11 +3812,13 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.1.5
'@rolldown/binding-win32-x64-msvc': 1.1.5
safe-buffer@5.2.1: {}
safe-buffer@5.2.1:
optional: true
safe-stable-stringify@2.5.0: {}
semver@6.3.1: {}
semver@6.3.1:
optional: true
semver@7.8.5: {}
@@ -4074,7 +3861,8 @@ snapshots:
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
signal-exit@3.0.7:
optional: true
sonic-boom@4.2.1:
dependencies:
@@ -4106,6 +3894,7 @@ snapshots:
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
optional: true
strip-ansi@6.0.1:
dependencies:
@@ -4123,6 +3912,7 @@ snapshots:
minizlib: 2.1.2
mkdirp: 1.0.4
yallist: 4.0.0
optional: true
thirty-two@1.0.2: {}
@@ -4161,7 +3951,8 @@ snapshots:
dependencies:
tldts: 7.4.9
tr46@0.0.3: {}
tr46@0.0.3:
optional: true
tree-kill@1.2.2: {}
@@ -4202,7 +3993,8 @@ snapshots:
undici@7.29.0: {}
util-deprecate@1.0.2: {}
util-deprecate@1.0.2:
optional: true
vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.15):
dependencies:
@@ -4238,7 +4030,8 @@ snapshots:
transitivePeerDependencies:
- msw
webidl-conversions@3.0.1: {}
webidl-conversions@3.0.1:
optional: true
werift-rtp@0.8.9(supports-color@7.2.0):
dependencies:
@@ -4252,6 +4045,7 @@ snapshots:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
optional: true
which-module@2.0.1: {}
@@ -4263,6 +4057,7 @@ snapshots:
wide-align@1.1.5:
dependencies:
string-width: 4.2.3
optional: true
wrap-ansi@6.2.0:
dependencies:
@@ -4270,7 +4065,8 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wrappy@1.0.2: {}
wrappy@1.0.2:
optional: true
ws@8.21.3: {}
@@ -4278,7 +4074,8 @@ snapshots:
y18n@4.0.3: {}
yallist@4.0.0: {}
yallist@4.0.0:
optional: true
yargs-parser@18.1.3:
dependencies:
+2 -180
View File
@@ -1,6 +1,4 @@
import { Client } from "discord.js-selfbot-v13";
import { inArray, lt } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { ConfigError, DatabaseError } from "@/shared/errors/index";
import { createChildLogger } from "@/shared/logger/index";
import {
@@ -33,144 +31,18 @@ import { startDigestScheduler } from "../modules/monitor/digestScheduler.js";
import { registerReactionCapture } from "../modules/reaction-tracking/index.js";
import { registerThreadCapture } from "../modules/thread-tracking/index.js";
import { registerPresenceCapture } from "../modules/user-presence/index.js";
import { VoicePcmWsClient } from "../modules/voice-pcm-ws/index.js";
import { startMuxerWorker } from "../modules/voice-recording/muxer.js";
import {
registerSelfVoiceStateGuard,
setPcmWsClient,
setEventBroadcaster as setRecorderEventBroadcaster,
} from "../modules/voice-recording/recorder.js";
import {
setVideoRecorderClient,
setVideoRecordingsDir,
} from "../modules/voice-recording/videoRecorder.js";
import { VoiceController } from "../modules/voice-recording/voiceController.js";
import { config } from "../shared/config/config.js";
import {
closeDatabase,
getDatabase,
initializeDatabase,
} from "../shared/database/drizzle.js";
import { runMigrations } from "../shared/database/migrate.js";
import type * as schema from "../shared/database/schema.js";
import {
attachmentsTable,
messagesTable,
voiceRecordingsTable,
} from "../shared/database/schema.js";
import { createDiscordClientOptions } from "../shared/discord/clientOptions.js";
import { startRetentionCleanup } from "./retention.js";
import { createGracefulShutdown } from "./shutdown.js";
const logger = createChildLogger("discord-gateway");
// ─── Retention Cleanup ─────────────────────────────────────────────────────
async function deleteExpiredRecords(
table: any,
timestampField: any,
days: number | undefined,
dryRun: boolean,
label: string,
): Promise<void> {
if (!days || days <= 0) {
logger.debug({ label }, `Retention disabled for ${label}`);
return;
}
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const db = getDatabase() as unknown as NodePgDatabase<typeof schema>;
const expired = await db
.select({ id: table.id })
.from(table)
.where(lt(timestampField, cutoff))
.limit(1000);
if (expired.length === 0) {
logger.debug({ label }, `No expired ${label} found`);
return;
}
logger.info({ count: expired.length, label }, `Found expired ${label}`);
if (dryRun) {
logger.info(
{ count: expired.length, label },
`[DRY RUN] Would delete ${expired.length} ${label}`,
);
return;
}
try {
await db.delete(table).where(
inArray(
table.id,
expired.map((r) => r.id),
),
);
logger.info({ count: expired.length, label }, `Deleted expired ${label}`);
} catch (err) {
logger.error({ err, label }, `Failed to delete expired ${label}`);
}
}
function startRetentionCleanup(): void {
const intervalMs = config.RETENTION_CLEANUP_INTERVAL_MS;
const dryRun = config.RETENTION_DRY_RUN;
logger.info(
{
intervalMs,
dryRun,
messagesDays: config.RETENTION_MESSAGES_DAYS,
attachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
voiceDays: config.RETENTION_VOICE_DAYS,
},
"Starting retention cleanup scheduler",
);
async function runCleanupTick(): Promise<void> {
await deleteExpiredRecords(
messagesTable,
messagesTable.created_at,
config.RETENTION_MESSAGES_DAYS,
dryRun,
"messages",
);
await deleteExpiredRecords(
attachmentsTable,
attachmentsTable.created_at,
config.RETENTION_ATTACHMENTS_DAYS,
dryRun,
"attachments",
);
await deleteExpiredRecords(
voiceRecordingsTable,
voiceRecordingsTable.created_at,
config.RETENTION_VOICE_DAYS,
dryRun,
"voice recordings",
);
}
// Run immediately on start, then schedule
runCleanupTick().catch((error) => {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Initial retention cleanup tick failed",
);
});
setInterval(() => {
runCleanupTick().catch((error) => {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Retention cleanup tick failed",
);
});
}, intervalMs);
}
// ─── Bootstrap ─────────────────────────────────────────────────────────────
export async function initializeDiscordGateway() {
@@ -188,13 +60,6 @@ export async function initializeDiscordGateway() {
logger.info("Creating Discord client");
const client = new Client(createDiscordClientOptions());
const voiceController = new VoiceController(client);
// Wire the video recorder (others' camera/screen share) to the selfbot's
// native watch/receive stack + its recordings dir. Best-effort: failures are
// logged inside, never fatal.
setVideoRecorderClient(client);
setVideoRecordingsDir(config.RECORDINGS_DIR);
// Initialize Redis event broadcaster
const redisPublisher = new RedisEventPublisher(config.REDIS_URL, logger);
@@ -203,33 +68,13 @@ export async function initializeDiscordGateway() {
// Initialize Redis command handler for backend→gateway commands
const commandHandler = new CommandHandler();
// Initialize Voice PCM WebSocket client (bypasses Redis for real-time audio)
let pcmWsClient: VoicePcmWsClient | undefined;
if (config.VOICE_PCM_WS_ENABLED && config.BACKEND_WS_TOKEN) {
pcmWsClient = new VoicePcmWsClient(
config.BACKEND_WS_URL,
config.BACKEND_WS_TOKEN,
);
pcmWsClient.connect();
setPcmWsClient(pcmWsClient);
logger.info({ url: config.BACKEND_WS_URL }, "Voice PCM WS client enabled");
} else if (config.VOICE_PCM_WS_ENABLED && !config.BACKEND_WS_TOKEN) {
logger.warn(
"VOICE_PCM_WS_ENABLED=true but BACKEND_WS_TOKEN is empty — falling back to Redis for PCM",
);
} else {
logger.info("Voice PCM WS disabled — using Redis for PCM");
}
const gracefulShutdown = createGracefulShutdown({
logger,
closeDatabase,
voiceController,
client,
eventBroadcaster,
commandHandler,
stopMetricsServer,
pcmWsClient,
});
try {
@@ -255,8 +100,6 @@ export async function initializeDiscordGateway() {
client.on("debug", (msg) => {
if (
msg.includes("[VOICE") ||
msg.includes("[ffmpeg") ||
msg.toLowerCase().includes("error") ||
msg.toLowerCase().includes("stream")
) {
@@ -269,7 +112,6 @@ export async function initializeDiscordGateway() {
client.on("ready", async () => {
logger.info({ user: client.user?.tag }, "Bot logged in");
setMessageCaptureEventBroadcaster(eventBroadcaster);
setRecorderEventBroadcaster(eventBroadcaster);
setModerationEventBroadcaster(eventBroadcaster);
registerMessageCapture(client);
startPendingAIAnalysisWorker(client, eventBroadcaster);
@@ -281,30 +123,10 @@ export async function initializeDiscordGateway() {
registerChannelTopicCapture(client, eventBroadcaster);
registerGuildMemberEvents(client, eventBroadcaster);
// Start background workers
startMuxerWorker();
// Start command handler after Discord is ready
commandHandler.start(client, voiceController);
commandHandler.start(client);
logger.info("Command handler started");
// Rejoin persisted voice channels (auto-reconnect on restart/reboot).
// Non-fatal: failures are logged inside autoReconnect.
void voiceController
.autoReconnect()
.catch((err) =>
logger.warn(
{ err: err instanceof Error ? err.message : String(err) },
"Voice auto-reconnect on startup failed",
),
);
// Attach the immediate self-undeafen/self-unmute guard so the bot reacts
// INSTANTLY when an admin server-mutes or server-deafens it (previously
// only re-asserted on video-watch/reconnect, leaving the bot muted for
// minutes).
registerSelfVoiceStateGuard(client);
// Start retention cleanup scheduler
startRetentionCleanup();
// Start weekly moderation digest (public, automated)
+23 -17
View File
@@ -1,22 +1,36 @@
import { inArray, lt } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type {
NodePgDatabase,
NodePgQueryResultHKT,
} from "drizzle-orm/node-postgres";
import { createChildLogger } from "@/shared/logger/index";
import { config } from "../shared/config/config.js";
import { getDatabase } from "../shared/database/drizzle.js";
import type * as schema from "../shared/database/schema.js";
import {
attachmentsTable,
messagesTable,
voiceRecordingsTable,
} from "../shared/database/schema.js";
import { attachmentsTable, messagesTable } from "../shared/database/schema.js";
const logger = createChildLogger("discord-gateway");
/** DB handle typed with the full schema so table/column refs resolve. */
type GatewayDatabase = NodePgDatabase<typeof schema>;
/** Tables eligible for retention cleanup: string `id` + numeric `created_at`. */
type RetentionTable = typeof messagesTable | typeof attachmentsTable;
type RetentionTimestampColumn =
| typeof messagesTable.created_at
| typeof attachmentsTable.created_at;
// ─── Retention Cleanup ─────────────────────────────────────────────────────
/**
* Delete rows older than `days` in `table`, in batches of up to 1000 ids.
* When `dryRun` is set, logs what would be deleted without deleting.
* Returns immediately (no-op) when `days` is unset or <= 0.
*/
async function deleteExpiredRecords(
table: any,
timestampField: any,
table: RetentionTable,
timestampField: RetentionTimestampColumn,
days: number | undefined,
dryRun: boolean,
label: string,
@@ -27,7 +41,7 @@ async function deleteExpiredRecords(
}
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const db = getDatabase() as unknown as NodePgDatabase<typeof schema>;
const db = getDatabase() as unknown as GatewayDatabase;
const expired = await db
.select({ id: table.id })
@@ -73,7 +87,6 @@ function startRetentionCleanup(): void {
dryRun,
messagesDays: config.RETENTION_MESSAGES_DAYS,
attachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
voiceDays: config.RETENTION_VOICE_DAYS,
},
"Starting retention cleanup scheduler",
);
@@ -93,13 +106,6 @@ function startRetentionCleanup(): void {
dryRun,
"attachments",
);
await deleteExpiredRecords(
voiceRecordingsTable,
voiceRecordingsTable.created_at,
config.RETENTION_VOICE_DAYS,
dryRun,
"voice recordings",
);
}
// Run immediately on start, then schedule
+9 -20
View File
@@ -3,9 +3,6 @@ import type { createChildLogger } from "@/shared/logger/index";
import type { CommandHandler } from "../modules/command-handler/commandHandler.js";
import type { EventBroadcaster } from "../modules/event-broadcaster/index.js";
import type { stopMetricsServer } from "../modules/gateway-metrics/index.js";
import type { VoicePcmWsClient } from "../modules/voice-pcm-ws/index.js";
import { stopMuxerWorker } from "../modules/voice-recording/muxer.js";
import type { VoiceController } from "../modules/voice-recording/voiceController.js";
import type { closeDatabase } from "../shared/database/drizzle.js";
type Logger = ReturnType<typeof createChildLogger>;
@@ -15,18 +12,21 @@ type StopMetricsServer = typeof stopMetricsServer;
export interface GracefulShutdownOptions {
logger: Logger;
closeDatabase: CloseDatabase;
voiceController: VoiceController;
client: Client;
eventBroadcaster: EventBroadcaster;
commandHandler: CommandHandler;
stopMetricsServer?: StopMetricsServer;
pcmWsClient?: VoicePcmWsClient;
}
export function createGracefulShutdown(options: GracefulShutdownOptions) {
export type GracefulShutdown = (signal: string) => Promise<void>;
/** Create a shutdown handler that can only be triggered once. */
export function createGracefulShutdown(
options: GracefulShutdownOptions,
): GracefulShutdown {
let isShuttingDown = false;
return async function gracefulShutdown(signal: string) {
return async function gracefulShutdown(signal: string): Promise<void> {
if (isShuttingDown) {
options.logger.warn(`Already shutting down, ignoring ${signal}`);
return;
@@ -37,26 +37,15 @@ export function createGracefulShutdown(options: GracefulShutdownOptions) {
try {
options.stopMetricsServer?.();
stopMuxerWorker();
// 1. Voice disconnect (may write final DB records)
options.logger.info("Stopping voice connection...");
await options.voiceController.disconnect();
// 1b. Close PCM WebSocket client (after voice, before Redis)
if (options.pcmWsClient) {
options.logger.info("Closing voice PCM WS client...");
await options.pcmWsClient.close();
}
// 2. Close Redis/pubsub after voice is done
// 1. Close Redis/pubsub
options.logger.info("Closing event broadcaster...");
await options.eventBroadcaster.close();
options.logger.info("Closing command handler...");
await options.commandHandler.close();
// 3. DB pool LAST – voice disconnect may finalize recordings
// 2. DB pool
options.logger.info("Closing database...");
await options.closeDatabase();
options.logger.info("Database closed");
-2
View File
@@ -1,5 +1,3 @@
import "libsodium-wrappers";
import "@snazzah/davey";
import "dotenv/config";
import { createChildLogger } from "@/shared/logger/index";
import { initializeDiscordGateway } from "./app/bootstrap.js";
@@ -5,41 +5,17 @@ import {
BACKEND_COMMAND,
type CommandMessage,
type CommandReply,
MEDIA_STATUS_KEY,
VOICE_STATUS_KEY,
} from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import type { VoiceController } from "../voice-recording/voiceController.js";
import { GuildHandler } from "./guild.handler.js";
import {
type CommandHandlerFn,
createHandlerRegistry,
} from "./handler-registry.js";
import { MediaHandler } from "./media.handler.js";
import { wireMediaStatusWriter } from "./mediaStatusSink.js";
import { ModerationHandler } from "./moderation.handler.js";
import { VideoHandler } from "./video.handler.js";
import { VoiceHandler } from "./voice.handler.js";
const logger = createChildLogger("command-handler");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface VoiceStatusPayload {
connected: boolean;
activeGuildId: string | null;
activeChannelId: string | null;
activeChannelName: string | null;
connections: Array<{
guildId: string;
channelId: string;
channelName: string;
connectedAt: number;
}>;
}
// ---------------------------------------------------------------------------
// CommandHandler
// ---------------------------------------------------------------------------
@@ -47,22 +23,18 @@ interface VoiceStatusPayload {
export class CommandHandler {
private redisSub: Redis;
private redisPub: Redis;
private voiceController: VoiceController | null = null;
private registry: Map<string, CommandHandlerFn> = new Map();
private voiceHandler!: VoiceHandler;
private mediaHandler!: MediaHandler;
private guildHandler!: GuildHandler;
private moderationHandler!: ModerationHandler;
private videoHandler!: VideoHandler;
constructor() {
// Dedicated Redis connection needed because: Redis requires a dedicated
// connection for SUBSCRIBE mode — a subscribed connection cannot perform
// publish/set operations. This connection listens on backend:command for
// inbound requests from the backend.
this.redisSub = new Redis(config.REDIS_URL); // Dedicated Redis connection needed because: Redis requires a dedicated
// PUBLISH connection (cannot share with redisSub which is in SUBSCRIBE mode).
// Handles command reply publishing and voice/media status key updates.
this.redisSub = new Redis(config.REDIS_URL);
// A second dedicated connection for PUBLISH — a connection in SUBSCRIBE
// mode cannot publish, so replies go out on this one.
this.redisPub = new Redis(config.REDIS_URL);
this.redisSub.on("error", (err) => {
@@ -77,30 +49,18 @@ export class CommandHandler {
// ---- Lifecycle ----
/**
* Attach the Discord client and VoiceController, then subscribe to the Redis
* command channel. Must be called *after* the Discord client is created.
* Attach the Discord client, then subscribe to the Redis command channel.
* Must be called *after* the Discord client is created.
*/
start(client: Client, voiceController: VoiceController): void {
this.voiceController = voiceController;
start(client: Client): void {
// Create domain-specific handlers with their dependencies
this.voiceHandler = new VoiceHandler(client, voiceController);
this.mediaHandler = new MediaHandler();
this.guildHandler = new GuildHandler(client);
this.moderationHandler = new ModerationHandler(client);
this.videoHandler = new VideoHandler(client, voiceController);
// Wire the media status sink so MediaHandler can persist status on
// queue advances that happen outside a command (natural track end).
wireMediaStatusWriter(this.redisPub);
// Build the command registry
this.registry = createHandlerRegistry(
this.voiceHandler,
this.mediaHandler,
this.guildHandler,
this.moderationHandler,
this.videoHandler,
);
this.redisSub.on("message", (_channel, message) => {
@@ -117,10 +77,6 @@ export class CommandHandler {
logger.info(`Subscribed to Redis channel "${BACKEND_COMMAND}"`);
}
});
// Publish initial status snapshots so the backend knows the starting state.
this.publishVoiceStatus();
this.publishMediaStatus();
}
async close(): Promise<void> {
@@ -129,6 +85,11 @@ export class CommandHandler {
// ---- Command dispatch ----
/** Normalize an unknown thrown value to a readable message. */
private static errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
private async handleCommand(raw: string): Promise<void> {
let cmd: CommandMessage;
try {
@@ -156,7 +117,7 @@ export class CommandHandler {
};
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = CommandHandler.errorMessage(err);
logger.error(
{ commandId: cmd.id, error: message },
"Command execution failed",
@@ -175,51 +136,5 @@ export class CommandHandler {
} catch (err) {
logger.error({ err }, "Failed to publish command reply");
}
// Always refresh status keys after every command so the backend has
// the latest snapshot without polling.
this.publishVoiceStatus();
this.publishMediaStatus();
}
// ---- Status publishing ----
private publishVoiceStatus(): void {
const raw = this.voiceController
? this.voiceController.getStatus()
: {
ready: false,
connected: false,
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
connections: [],
};
const status: VoiceStatusPayload = {
connected: raw.connected,
activeGuildId: raw.activeGuildId,
activeChannelId: raw.activeChannelId,
activeChannelName: raw.activeChannelName,
connections: raw.connections ?? [],
};
this.setKey(VOICE_STATUS_KEY, JSON.stringify(status));
}
private publishMediaStatus(): void {
this.setKey(
MEDIA_STATUS_KEY,
JSON.stringify(this.mediaHandler.getCurrentMediaStatus()),
);
}
/**
* Fire-and-forget SET using the persistent Redis publisher connection.
*/
private setKey(key: string, value: string): void {
this.redisPub.set(key, value).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn({ key, error: msg }, "Failed to update Redis status key");
});
}
}

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