Files
GMW/docs/specs/2026-08-31_video-receive-dave-streamwatch-spec.md
T

12 KiB
Raw Blame History

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).