6.1 KiB
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)
ClientVoiceManager.joinChannel(channel, config)→ a selfbotVoiceConnectionwith.receiver(VoiceReceiver→PacketHandler). [ClientVoiceManager.js:102-118]VoiceConnection.receiveris created in the constructor. [VoiceConnection.js:140]VoiceReceiver.createVideoStream(user, output)→PacketHandler.makeVideoStream→Recorder(ffmpeg that muxes H264+Opus RTP over UDP → Matroska (.mkv)). [Receiver.js, Recorder.js]PacketHandlerroutes: video RTP → Recorder UDP 65506, opus RTP → UDP 65510; decodes all viaconnection.authentication.{secret_key, mode}(supportsaead_aes256_gcm_rtpsizeandaead_xchacha20_poly1305_rtpsize= DAVE-compatible). [PacketHandler.js:115-155, 195-240]StreamConnectionReadonly.joinStreamConnection(userId)+sendSignalScreenshare()sends gateway opSTREAM_WATCHso Discord actually forwards the streamer's RTP to us. [VoiceConnection.js:1100-1240]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
joinChannelreusesClientVoiceManager.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 === truefor 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
VoiceConnectionexists for the channel (spike:client.voice.joinChannel(channel), fallback: build aVoiceConnectiondirectly 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
muxToMp4can 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
.mkvper 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
pnpm typecheck+pnpm build+ biome clean in discord-gateway.- Unit: Recorder wiring + streamer detection with mocked VoiceConnection.
- Live (deploy): user shares screen → journal shows
STREAM_WATCHsent +Recorder ready+.mkvfile appears under recordings dir; playable via ffmpeg. - CI Build & Deploy (Nix) green.
Open questions for spike (before full build)
- Can
client.voice.joinChannel()run alongside the active@discordjs/voicesession, or does the singletonClientVoiceManager.connectioncollide / tear down the existing audio connection? - Does the selfbot
VoiceConnectionneed the bot'svideo: trueflag in IDENTIFY to receive video (it advertisesstreamsin 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)?