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)
This commit is contained in:
asepharyana
2026-09-02 02:03:31 +07:00
parent cee33c0d1f
commit ac32179bb1
2 changed files with 168 additions and 12 deletions
@@ -40,6 +40,22 @@ const logger = createChildLogger("video-recorder");
/** Channels currently under audio recording, keyed by guildId. */
const watchedChannels = new Map<string, VoiceChannel>();
/**
* GUILD_CREATE.voice_states captured at gateway WS connect (the selfbot lib
* drops them), keyed by guildId. Consumed by scanExistingStreamers when the
* channel is tracked (voice join happens AFTER GUILD_CREATE, so we must store
* these until trackChannel runs).
*/
const pendingGuildCreateVoiceStates = new Map<
string,
Array<{
user_id?: unknown;
channel_id?: unknown;
self_video?: boolean;
self_stream?: boolean;
}>
>();
let _client: Client | undefined;
let _listenerAttached = false;
/** Resolved at runtime from config (kept default as fallback). */
@@ -53,6 +69,7 @@ export function setVideoRecordingsDir(dir: string) {
/** Test-only: clear all module-level state. */
export function __resetVideoRecorderState(): void {
watchedChannels.clear();
pendingGuildCreateVoiceStates.clear();
_listenerAttached = false;
_client = undefined;
}
@@ -73,6 +90,21 @@ export function setVideoRecorderClient(client: Client | undefined) {
void handleVoiceStateUpdate(oldState, newState);
},
);
// THE selfbot-v13 GUILD_CREATE handler DROPS `d.voice_states` (it only sends
// GUILD_SUBSCRIPTIONS_BULK, never parses the initial voice-state list), so
// `channel.members` is empty on gateway restart and users who were ALREADY
// in voice (camera/screen on) are never detected. Hook the raw GUILD_CREATE
// payload ourselves and scan the voice_states array — this is the only
// user-token-compatible source of "who is in the channel right now".
client.on("raw", (packet: unknown) => {
const p = packet as {
t?: string;
d?: { voice_states?: unknown; id?: unknown };
} | null;
if (!p?.t || p.t !== "GUILD_CREATE") return;
const vs = p.d?.voice_states;
handleGuildCreateVoiceStates(p.d as Record<string, unknown>, vs);
});
}
/** Register a channel whose audio recording is active (video should follow). */
@@ -92,12 +124,13 @@ export function trackChannel(guildId: string, channel: VoiceChannel) {
* already on when the bot joined). Fire-and-forget per member — idempotent
* (startVideoRecording is a no-op if a watch already exists for that user).
*
* BUG FIX (2026-09-02): `channel.members` is often EMPTY after a gateway
* restart because the selfbot's guild member cache hasn't been populated yet.
* When that happens, fall back to the Discord REST voice-states endpoint which
* returns all users currently in the voice channel with their live voice state
* (self_video, self_stream, etc.). This ensures we detect pre-existing
* camera/screen-share users even on a cold start.
* BUG FIX (2026-09-02 — user report "tidak mendeteksi user yg sudah ada"):
* `channel.members` is empty after a gateway restart because (a) the selfbot
* library's GUILD_CREATE handler DROPS `d.voice_states` (it only sends
* GUILD_SUBSCRIPTIONS_BULK, never parses the initial voice state list), and
* (b) guild member cache isn't populated yet. We now capture voice_states
* raw (Path C, the reliable selfbot-compatible source), fall back to
* guild.members.fetch() (Path D, may 403 for user tokens), then cache.
*/
export async function scanExistingStreamers(
channel: VoiceChannel,
@@ -118,14 +151,32 @@ export async function scanExistingStreamers(
}
}
// ── Path B: cache empty → fetch guild members via REST ────────────────
// ── Path C: GUILD_CREATE.voice_states (buffered at WS connect) ─────────
// The selfbot lib drops `d.voice_states` from GUILD_CREATE, but we capture
// it in the raw listener. This is the ONLY selfbot-compatible source of
// "who is already in voice with video" — not gated by REST permissions.
if (videoUsers.length === 0) {
const vsList = pendingGuildCreateVoiceStates.get(channel.guild.id);
if (Array.isArray(vsList) && vsList.length > 0) {
for (const vs of vsList) {
const uid = String(vs.user_id ?? "");
if (!uid || uid === selfId) continue;
if (String(vs.channel_id ?? "") !== channel.id) continue;
const hasVideo = Boolean(vs.self_video || vs.self_stream);
if (!hasVideo) continue;
videoUsers.push({ userId: uid, source: "guild-create-voice-states" });
}
}
}
// ── Path D: cache empty → fetch guild members via REST ────────────────
// GET /channels/{id}/voice-states is BOT-ONLY since 2023 — user tokens get
// 404. The user-token-compatible way to discover who is in the voice
// channel: fetch the guild member list (GET /guilds/{id}/members — used
// elsewhere by the selfbot) which populates member.voice states, then scan
// `channel.members` again. Fire-and-forget retry: if it still looks empty
// after one fetch, we give up quietly (voiceStateUpdate will catch anyone
// who toggles later).
// `channel.members` again. NOTE: user tokens may also get 403 on this REST
// path (verified 2026-09-02) — Path C is the reliable one; Path D is a
// best-effort fallback for real bot tokens.
if (videoUsers.length === 0 && members && members.size === 0 && _client) {
try {
await channel.guild.members.fetch();
@@ -181,6 +232,40 @@ export function untrackChannel(guildId: string) {
stopAllStreamWatches(guildId);
}
/**
* Parse `GUILD_CREATE.d.voice_states` and buffer them for when trackChannel
* runs. This is the authoritative, user-token-friendly list of everyone
* already in voice when the gateway connects — but GUILD_CREATE arrives BEFORE
* the voice join, so we can't start watches yet. Store per-guild and consume
* in scanExistingStreamers.
*/
function handleGuildCreateVoiceStates(
data: Record<string, unknown>,
voiceStates: unknown,
): void {
try {
const guildId = String(data.id ?? data.guild_id ?? "");
if (!guildId) return;
if (!Array.isArray(voiceStates)) return;
const list = voiceStates as Array<{
user_id?: unknown;
channel_id?: unknown;
self_video?: boolean;
self_stream?: boolean;
}>;
pendingGuildCreateVoiceStates.set(guildId, list);
logger.info(
{ guildId, voiceStates: list.length },
"Buffered GUILD_CREATE.voice_states for pre-existing video detection",
);
} catch (err) {
logger.warn(
{ err: err instanceof Error ? err.message : String(err) },
"GUILD_CREATE voice_states parse error (ignoring)",
);
}
}
async function handleVoiceStateUpdate(
_oldState: VoiceState,
newState: VoiceState,
@@ -45,8 +45,9 @@ describe("videoRecorder (stream-watch orchestration)", () => {
const client = makeClient();
setVideoRecorderClient(client);
setVideoRecorderClient(client); // idempotent (videoRecorder + streamWatch)
// one raw (streamWatch) + one voiceStateUpdate (videoRecorder)
expect(client.on).toHaveBeenCalledTimes(2);
// one voiceStateUpdate (videoRecorder) + raw GUILD_CREATE (videoRecorder)
// + raw STREAM_* (streamWatch) = 3
expect(client.on).toHaveBeenCalledTimes(3);
expect(client.on).toHaveBeenCalledWith(
"voiceStateUpdate",
expect.any(Function),
@@ -190,4 +191,74 @@ describe("videoRecorder (stream-watch orchestration)", () => {
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");
});
});