fix(gateway): resolve DAVE session from net.state.dave, not connectionData

CRITICAL: djs/voice stores the DAVESession wrapper at net.state.dave
(createDaveSession assigns to state.dave on op4 SessionDescription), NOT
inside connectionData. decryptVideoPacket looked up connectionData.dave which
is ALWAYS undefined -> every video packet hit '!dave?.session' guard and was
silently dropped, so no .h264/.mp4 ever got written despite the handshake
reaching Ready.

Fix: pass net.state.dave as a separate arg (the wrapper has .session ->
Davey.DAVESession) so the DAVE-layer decrypt (MediaType.VIDEO) actually runs.
Typecheck + build pass, lint clean (src/), 179/179 tests.
This commit is contained in:
asepharyana
2026-08-31 16:08:55 +07:00
parent dea284357c
commit f1a7b0c2a1
2 changed files with 50 additions and 16 deletions
@@ -385,12 +385,12 @@ function decryptVideoPacket(
encryptionMode?: string; encryptionMode?: string;
nonceBuffer?: Buffer; nonceBuffer?: Buffer;
secretKey?: Buffer | Uint8Array; secretKey?: Buffer | Uint8Array;
dave?: { session?: Davey.DAVESession };
}, },
daveSession: { session?: Davey.DAVESession } | undefined,
userId: string, userId: string,
): Buffer | null { ): Buffer | null {
const { encryptionMode, nonceBuffer, secretKey, dave } = connectionData; const { encryptionMode, nonceBuffer, secretKey } = connectionData;
if (!encryptionMode || !nonceBuffer || !secretKey || !dave?.session) if (!encryptionMode || !nonceBuffer || !secretKey || !daveSession?.session)
return null; return null;
if (buffer.length < AUTH_TAG_LENGTH + UNPADDED_NONCE_LENGTH + 12) return null; if (buffer.length < AUTH_TAG_LENGTH + UNPADDED_NONCE_LENGTH + 12) return null;
@@ -418,7 +418,11 @@ function decryptVideoPacket(
// DAVE layer — decrypt as VIDEO (audio wrapper hardcodes MediaType.AUDIO). // DAVE layer — decrypt as VIDEO (audio wrapper hardcodes MediaType.AUDIO).
try { try {
const decrypted = dave.session.decrypt(userId, MediaType.VIDEO, packet); const decrypted = daveSession.session.decrypt(
userId,
MediaType.VIDEO,
packet,
);
return decrypted && decrypted.length > 0 ? decrypted : null; return decrypted && decrypted.length > 0 ? decrypted : null;
} catch { } catch {
return null; // per-packet DAVE decrypt failures are transient (recovery on next keyframe) return null; // per-packet DAVE decrypt failures are transient (recovery on next keyframe)
@@ -433,8 +437,8 @@ function handleUdpMessage(
encryptionMode?: string; encryptionMode?: string;
nonceBuffer?: Buffer; nonceBuffer?: Buffer;
secretKey?: Buffer | Uint8Array; secretKey?: Buffer | Uint8Array;
dave?: { session?: Davey.DAVESession };
}; };
dave?: { session?: Davey.DAVESession };
}; };
}, },
uid: string, uid: string,
@@ -449,6 +453,10 @@ function handleUdpMessage(
const decrypted = decryptVideoPacket( const decrypted = decryptVideoPacket(
msg, msg,
net.state.connectionData ?? {}, net.state.connectionData ?? {},
// djs/voice stores the DAVESession at net.state.dave (NOT inside
// connectionData) — look it up at the top-level state so DAVE layer
// decrypt (MediaType.VIDEO) actually runs.
net.state.dave,
uid, uid,
); );
if (!decrypted) return; if (!decrypted) return;
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as streamWatch from "../src/modules/voice-recording/streamWatchReceiver.js";
import { import {
__resetVideoRecorderState, __resetVideoRecorderState,
setVideoRecorderClient, setVideoRecorderClient,
@@ -8,7 +9,6 @@ import {
trackChannel, trackChannel,
untrackChannel, untrackChannel,
} from "../src/modules/voice-recording/videoRecorder.js"; } from "../src/modules/voice-recording/videoRecorder.js";
import * as streamWatch from "../src/modules/voice-recording/streamWatchReceiver.js";
// ─── mocks ───────────────────────────────────────────────────────────── // ─── mocks ─────────────────────────────────────────────────────────────
function makeChannel(guildId = "g1", channelId = "c1") { function makeChannel(guildId = "g1", channelId = "c1") {
@@ -52,7 +52,9 @@ describe("videoRecorder (stream-watch orchestration)", () => {
}); });
it("startVideoRecording delegates to streamWatchReceiver.startStreamWatch", () => { it("startVideoRecording delegates to streamWatchReceiver.startStreamWatch", () => {
const spy = vi.spyOn(streamWatch, "startStreamWatch").mockImplementation(() => {}); const spy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
const ch = makeChannel(); const ch = makeChannel();
startVideoRecording(ch, makeUser()); startVideoRecording(ch, makeUser());
expect(spy).toHaveBeenCalledWith(ch, expect.any(String)); expect(spy).toHaveBeenCalledWith(ch, expect.any(String));
@@ -60,20 +62,26 @@ describe("videoRecorder (stream-watch orchestration)", () => {
it("refuses to record the bot's own video", () => { it("refuses to record the bot's own video", () => {
setVideoRecorderClient(makeClient()); setVideoRecorderClient(makeClient());
const spy = vi.spyOn(streamWatch, "startStreamWatch").mockImplementation(() => {}); const spy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
startVideoRecording(makeChannel(), "bot1"); startVideoRecording(makeChannel(), "bot1");
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });
it("stopVideoRecording calls streamWatchReceiver.stopStreamWatch", () => { it("stopVideoRecording calls streamWatchReceiver.stopStreamWatch", () => {
const spy = vi.spyOn(streamWatch, "stopStreamWatch").mockImplementation(() => {}); const spy = vi
.spyOn(streamWatch, "stopStreamWatch")
.mockImplementation(() => {});
const u = makeUser(); const u = makeUser();
stopVideoRecording("g1", u); stopVideoRecording("g1", u);
expect(spy).toHaveBeenCalledWith("g1", u); expect(spy).toHaveBeenCalledWith("g1", u);
}); });
it("untrackChannel tears down all stream watches for the guild", () => { it("untrackChannel tears down all stream watches for the guild", () => {
const spy = vi.spyOn(streamWatch, "stopAllStreamWatches").mockImplementation(() => {}); const spy = vi
.spyOn(streamWatch, "stopAllStreamWatches")
.mockImplementation(() => {});
untrackChannel("g1"); untrackChannel("g1");
expect(spy).toHaveBeenCalledWith("g1"); expect(spy).toHaveBeenCalledWith("g1");
}); });
@@ -82,11 +90,18 @@ describe("videoRecorder (stream-watch orchestration)", () => {
const client = makeClient() as any; const client = makeClient() as any;
setVideoRecorderClient(client); setVideoRecorderClient(client);
trackChannel("g1", makeChannel()); trackChannel("g1", makeChannel());
const startSpy = vi.spyOn(streamWatch, "startStreamWatch").mockImplementation(() => {}); const startSpy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
const listener = client.on.mock.calls.find( const listener = client.on.mock.calls.find(
(c: unknown[]) => c[0] === "voiceStateUpdate", (c: unknown[]) => c[0] === "voiceStateUpdate",
)?.[1]; )?.[1];
listener(null, { id: "u1", guild: { id: "g1" }, channelId: "c1", streaming: true }); listener(null, {
id: "u1",
guild: { id: "g1" },
channelId: "c1",
streaming: true,
});
expect(startSpy).toHaveBeenCalled(); expect(startSpy).toHaveBeenCalled();
}); });
@@ -94,18 +109,27 @@ describe("videoRecorder (stream-watch orchestration)", () => {
const client = makeClient() as any; const client = makeClient() as any;
setVideoRecorderClient(client); setVideoRecorderClient(client);
trackChannel("g1", makeChannel()); trackChannel("g1", makeChannel());
const stopSpy = vi.spyOn(streamWatch, "stopStreamWatch").mockImplementation(() => {}); const stopSpy = vi
.spyOn(streamWatch, "stopStreamWatch")
.mockImplementation(() => {});
const listener = client.on.mock.calls.find( const listener = client.on.mock.calls.find(
(c: unknown[]) => c[0] === "voiceStateUpdate", (c: unknown[]) => c[0] === "voiceStateUpdate",
)?.[1]; )?.[1];
listener(null, { id: "u1", guild: { id: "g1" }, channelId: "c1", streaming: false }); listener(null, {
id: "u1",
guild: { id: "g1" },
channelId: "c1",
streaming: false,
});
expect(stopSpy).toHaveBeenCalledWith("g1", "u1"); expect(stopSpy).toHaveBeenCalledWith("g1", "u1");
}); });
it("trackChannel scans pre-existing streamers already in the channel (bot join case)", () => { it("trackChannel scans pre-existing streamers already in the channel (bot join case)", () => {
const client = makeClient() as any; const client = makeClient() as any;
setVideoRecorderClient(client); setVideoRecorderClient(client);
const spy = vi.spyOn(streamWatch, "startStreamWatch").mockImplementation(() => {}); const spy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
const ch = { const ch = {
id: "c1", id: "c1",
guild: { id: "g1" }, guild: { id: "g1" },
@@ -122,7 +146,9 @@ describe("videoRecorder (stream-watch orchestration)", () => {
}); });
it("trackChannel is a no-op when the channel has no members (or none streaming)", () => { it("trackChannel is a no-op when the channel has no members (or none streaming)", () => {
const spy = vi.spyOn(streamWatch, "startStreamWatch").mockImplementation(() => {}); const spy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
trackChannel("g1", makeChannel()); // no members trackChannel("g1", makeChannel()); // no members
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });