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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user