12 KiB
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 repliesSTREAM_CREATE(rtc_server_id)STREAM_SERVER_UPDATE(separate token+endpoint) → client opens its own voice WS+UDP to that endpoint (StreamConnectionReadonlyin 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) supportsMediaType.VIDEO+Codec.H264decrypt — 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 insrc/client/voice/BaseMediaConnection.ts(DaveyDAVESessioninit viainitDave, MLS key-package / proposals / commit / welcome / transitions;WebRtcConnWrapperencrypts audio/video viadaveSession.encrypt(MediaType.VIDEO, codec, …)). BUT it is STREAMING only (send). No STREAM_WATCH / receive. @discordjs/voice: full DAVE receive for AUDIO only;DAVESession.decrypthardcodesMediaType.AUDIO(dist ~line 892);onUdpMessagedrops non-opus; no STREAM_WATCH.discord.js-selfbot-v13: video receive but no DAVE.
- Discord-RE/Discord-video-stream (fork of
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 calldaveSession.decrypt(userId, MediaType.VIDEO, packet)(Davey exposesMediaType.VIDEO+Codec.H264) — djs/voice's hardcodedAUDIOis the only blocker, fix by invoking Davey directly withMediaType.VIDEOfor 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 invideoReceiver.ts, unit-tested). - No new runtime deps. Risk: relies on non-exported djs/voice internals (reachable
via
as any, as the existingparsePacketusage 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)(viaclient.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. onUdpMessagewrapper: for video payload types, decrypt withdaveSession.decrypt(userId, MediaType.VIDEO, buf), depacketize, write.- teardown on STREAM_DELETE / user stops streaming / leave / channel untrack.
recorder.ts: wiretrackChannel/untrackChannelalready exist; ensure the selfbot eager voice connection attempt is REMOVED (it only 4017-spams logs) — but KEEPclient.ws.broadcastavailability for STREAM_WATCH.videoRecorder.ts: remove the dead selfbotjoinChannel/joinStreamConnectioncalls; keep thevoiceStateUpdatestreaming detection + teardown bookkeeping as the entry point; delegate the actual receive tostreamWatchReceiver.videoReceiver.ts: keepH264Depacketizer+muxToMp4(reused). The guild-sockethookVideoReceivercan be removed or left inert.- Tests:
tests/streamWatchReceiver.test.ts(DAVE-handshake stub, RTP decrypt path with a mocked Davey, STREAM_WATCH packet shape); keeptests/videoReceiver.test.ts.
Phases (each independently verifiable)
- Phase 1 (this session): spec + source reconnaissance. Confirm djs/voice
internals are reachable (VoiceWebSocket/VoiceUDPSocket/DAVESession exports &
shapes), confirm Davey
MediaType.VIDEOdecrypt signature, confirm how a raw stream-watch connection's identify/select-protocol flows. Verify the selfbotstreamKeyformat +rawSTREAM_CREATE/SERVER_UPDATE payload. GATE: accurate spec + no unknowns blocking A. - 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.
- Phase 3: gateway integration per files-touched. GATE: typecheck + build + biome + unit tests green; CI deploy ok.
- 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/.mkvon 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/VoiceUDPSocketreachability viaas anymust 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 am=video ... inactivesection. 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 lintgreen 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/voice0.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 — noas anyneeded for the heavy lifting.Networkingchild 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 vianew VoiceUDPSocket({ip, port})after Ready gives modes + ssrc.DAVESessionwraps@snazzah/daveyDavey.DAVESession(protocolVersion, userId, channelId); on network packets it callsthis.session.decrypt(userId, Davey.MediaType.AUDIO, packet)— hardcoded AUDIO (line ~892). For video we call Davey directly withMediaType.VIDEO+Codec.H264.@snazzah/daveyMediaType enum: AUDIO=0, VIDEO=1; Codec H264=4; methodsdecrypt(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 gatewayraweventsSTREAM_CREATE(d.rtc_server_id) +STREAM_SERVER_UPDATE(d.token,d.endpoint); selfbot routes them to the stream connection viaclient.on('raw')matchingd.stream_key, settingsetSessionId(sessionId)+setTokenAndEndpoint(token, endpoint)(Watch case inStreamConnectionReadonly). - Discord-RE reference for the identify SDP: stream connections send a
m=videosection witha=inactive(receive-only-ish) + standard DAVE/VoiceOpCodes (op 0 identify, op 2 select protocol incl.max_dave_protocol_version). SeeBaseMediaConnection.handleProtocolAck+initDave. - Decision: proceed with Strategy A. Selfbot code to REMOVE: the eager
ensureSelfbotVoicejoin +joinStreamConnection/receiver.createVideoStreaminvideoRecorder.ts(proven dead — 4017). Keep thevoiceStateUpdatestreaming detection + bookkeeping; swap the receive plumbing to a newstreamWatchReceiverdriven by a djs/voice-style connection. STREAM_WATCH itself still sent via the selfbotclient.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).