Files
GMW/services/discord-gateway/tests/videoRecorder.test.ts
T
asepharyana ac32179bb1 fix(video): detect pre-existing voice users from GUILD_CREATE.voice_states
Real root cause of 'tidak mendeteksi user yg sudah ada di voice':

The selfbot's discord.js-selfbot-v13 GUILD_CREATE handler only sends
GUILD_SUBSCRIPTIONS_BULK — it DROPS d.voice_states from the payload.
So when the gateway (re)starts, every user who was ALREADY in the voice
channel (with camera or screen-share on) is invisible: channel.members is
empty, and REST fallbacks don't work for user tokens (verified live:
GET /channels/{id}/voice-states -> 404, GET /guilds/{id}/members -> 403).

Fix: register our own raw listener for GUILD_CREATE, capture
d.voice_states per guild (buffer it), and consume it in
scanExistingStreamers when the channel gets tracked (voice join happens
after GUILD_CREATE). This is the ONLY user-token-compatible source of
'who is in voice with video right now'.

- videoRecorder: +pendingGuildCreateVoiceStates buffer, +raw GUILD_CREATE
  listener, Path C consumes buffered states (self_video/self_stream,
  matching channel, skip self) before the REST fallback
- scanExistingStreamers: Path A cache -> Path C GUILD_CREATE -> Path D
  REST members.fetch() best-effort
- +1 test: GUILD_CREATE voice_states buffer -> watch camera+screen-share,
  skip self/no-video/other-channel (192/192 pass)
- typecheck + build + biome clean (1 pre-existing warning)
2026-09-02 02:03:31 +07:00

265 lines
8.6 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import * as streamWatch from "../src/modules/voice-recording/streamWatchReceiver.js";
import {
__resetVideoRecorderState,
setVideoRecorderClient,
setVideoRecordingsDir,
startVideoRecording,
stopVideoRecording,
trackChannel,
untrackChannel,
} from "../src/modules/voice-recording/videoRecorder.js";
// ─── mocks ─────────────────────────────────────────────────────────────
function makeChannel(guildId = "g1", channelId = "c1") {
return {
id: channelId,
guild: {
id: guildId,
members: { fetch: vi.fn().mockResolvedValue(new Map()) },
},
};
}
function makeClient() {
return {
user: { id: "bot1" },
on: vi.fn(),
};
}
let seq = 0;
function makeUser() {
seq += 1;
return `user-${seq}`;
}
beforeEach(() => {
__resetVideoRecorderState();
vi.restoreAllMocks();
setVideoRecordingsDir("/tmp/gmw-vidrec-test");
});
describe("videoRecorder (stream-watch orchestration)", () => {
it("registers a voiceStateUpdate + raw listener on the client (idempotent)", () => {
const client = makeClient();
setVideoRecorderClient(client);
setVideoRecorderClient(client); // idempotent (videoRecorder + streamWatch)
// one voiceStateUpdate (videoRecorder) + raw GUILD_CREATE (videoRecorder)
// + raw STREAM_* (streamWatch) = 3
expect(client.on).toHaveBeenCalledTimes(3);
expect(client.on).toHaveBeenCalledWith(
"voiceStateUpdate",
expect.any(Function),
);
expect(client.on).toHaveBeenCalledWith("raw", expect.any(Function));
});
it("startVideoRecording delegates to streamWatchReceiver.startStreamWatch", () => {
const spy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
const ch = makeChannel();
startVideoRecording(ch, makeUser());
expect(spy).toHaveBeenCalledWith(ch, expect.any(String));
});
it("refuses to record the bot's own video", () => {
setVideoRecorderClient(makeClient());
const spy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
startVideoRecording(makeChannel(), "bot1");
expect(spy).not.toHaveBeenCalled();
});
it("stopVideoRecording calls streamWatchReceiver.stopStreamWatch", () => {
const spy = vi
.spyOn(streamWatch, "stopStreamWatch")
.mockImplementation(() => {});
const u = makeUser();
stopVideoRecording("g1", u);
expect(spy).toHaveBeenCalledWith("g1", u);
});
it("untrackChannel tears down all stream watches for the guild", () => {
const spy = vi
.spyOn(streamWatch, "stopAllStreamWatches")
.mockImplementation(() => {});
untrackChannel("g1");
expect(spy).toHaveBeenCalledWith("g1");
});
it("the voiceStateUpdate handler starts a watch when a member starts streaming", () => {
const client = makeClient() as any;
setVideoRecorderClient(client);
trackChannel("g1", makeChannel());
const startSpy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
const listener = client.on.mock.calls.find(
(c: unknown[]) => c[0] === "voiceStateUpdate",
)?.[1];
listener(null, {
id: "u1",
guild: { id: "g1" },
channelId: "c1",
streaming: true,
});
expect(startSpy).toHaveBeenCalled();
});
it("the voiceStateUpdate handler stops a watch when a member stops streaming", () => {
const client = makeClient() as any;
setVideoRecorderClient(client);
trackChannel("g1", makeChannel());
const stopSpy = vi
.spyOn(streamWatch, "stopStreamWatch")
.mockImplementation(() => {});
const listener = client.on.mock.calls.find(
(c: unknown[]) => c[0] === "voiceStateUpdate",
)?.[1];
listener(null, {
id: "u1",
guild: { id: "g1" },
channelId: "c1",
streaming: false,
});
expect(stopSpy).toHaveBeenCalledWith("g1", "u1");
});
it("trackChannel scans pre-existing streamers already in the channel (bot join case)", () => {
const client = makeClient() as any;
setVideoRecorderClient(client);
const spy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
const ch = {
id: "c1",
guild: { id: "g1" },
members: new Map([
["u1", { id: "u1", voice: { streaming: true } }], // already streaming
["u2", { id: "u2", voice: { streaming: false } }], // not streaming
["bot1", { id: "bot1", voice: { streaming: true } }], // bot itself
]),
};
trackChannel("g1", ch as any);
// only the already-streaming non-bot member gets watched
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(ch, "u1");
});
it("trackChannel is a no-op when the channel has no members (or none streaming)", () => {
const spy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
trackChannel("g1", makeChannel()); // no members
expect(spy).not.toHaveBeenCalled();
});
it("trackChannel falls back to REST guild.members.fetch() when cache is empty (cold start bug fix)", async () => {
const client = makeClient() as any;
setVideoRecorderClient(client);
const spy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
// Warmable guild: members.fetch() populates channel.members with a
// camera-on member (selfVideo) — the cold-start scenario where the bot
// joins a channel that already has someone on camera but the member cache
// hasn't been populated yet.
const cameraMember = {
id: "u-cam",
voice: { streaming: false, selfVideo: true },
};
const ch = {
id: "c1",
guild: {
id: "g1",
members: {
fetch: vi.fn().mockImplementation(async () => {
// Simulate REST fetch populating the channel member cache.
(ch as any).members = new Map([["u-cam", cameraMember]]);
return new Map([["u-cam", cameraMember]]);
}),
},
},
members: new Map(), // empty at join time — cold cache
};
trackChannel("g1", ch as any);
// Allow the async scanExistingStreamers (fire-and-forget) to complete.
await vi.waitFor(() => {
expect(spy).toHaveBeenCalledWith(ch as any, "u-cam");
});
});
it("detects pre-existing camera user from buffered GUILD_CREATE.voice_states (selfbot fix)", async () => {
const client = makeClient() as any;
setVideoRecorderClient(client);
const spy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
const ch = {
id: "c1",
guild: {
id: "g1",
members: { fetch: vi.fn().mockResolvedValue(new Map()) },
},
members: new Map(), // empty — library drops voice_states
};
// Simulate GUILD_CREATE arriving at WS connect (after setVideoRecorderClient
// registered the raw listener, before trackChannel runs).
const rawListener = client.on.mock.calls.find(
(c: unknown[]) => c[0] === "raw",
)?.[1];
expect(rawListener).toBeDefined();
rawListener({
t: "GUILD_CREATE",
d: {
id: "g1",
voice_states: [
{
user_id: "u-cam",
channel_id: "c1",
self_video: true,
self_stream: false,
},
{
user_id: "u-share",
channel_id: "c1",
self_video: false,
self_stream: true,
},
{
user_id: "u-novideo",
channel_id: "c1",
self_video: false,
self_stream: false,
},
{
user_id: "bot1",
channel_id: "c1",
self_video: true,
self_stream: false,
}, // self — skip
{
user_id: "u-otherchan",
channel_id: "c9",
self_video: true,
self_stream: false,
}, // different channel — skip
],
},
});
// Now track the channel — scanExistingStreamers consumes buffered states.
trackChannel("g1", ch as any);
await vi.waitFor(() => {
// camera user + screen-share user watched, self + no-video + other-channel skipped
expect(spy).toHaveBeenCalledWith(ch as any, "u-cam");
expect(spy).toHaveBeenCalledWith(ch as any, "u-share");
});
expect(spy).not.toHaveBeenCalledWith(ch as any, "u-novideo");
expect(spy).not.toHaveBeenCalledWith(ch as any, "bot1");
expect(spy).not.toHaveBeenCalledWith(ch as any, "u-otherchan");
});
});