# 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:::`. - 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 `` 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:::` 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).