Merge branch 'fix/remove-voice-recording'
This commit is contained in:
@@ -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,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).
|
||||
@@ -1,3 +0,0 @@
|
||||
*
|
||||
!.gitkeep
|
||||
!README.md
|
||||
@@ -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).
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+2
-208
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
Generated
+74
-277
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user