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)
265 lines
8.6 KiB
TypeScript
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");
|
|
});
|
|
});
|