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:
1 parent
cee33c0d1f
commit
ac32179bb1
2 files changed
+168
-12
No files matched your search
@@ -40,6 +40,22 @@ const logger = createChildLogger("video-recorder");
|
|||||||
/** Channels currently under audio recording, keyed by guildId. */
|
/** Channels currently under audio recording, keyed by guildId. */
|
||||||
const watchedChannels = new Map<string, VoiceChannel>();
|
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 _client: Client | undefined;
|
||||||
let _listenerAttached = false;
|
let _listenerAttached = false;
|
||||||
/** Resolved at runtime from config (kept default as fallback). */
|
/** 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. */
|
/** Test-only: clear all module-level state. */
|
||||||
export function __resetVideoRecorderState(): void {
|
export function __resetVideoRecorderState(): void {
|
||||||
watchedChannels.clear();
|
watchedChannels.clear();
|
||||||
|
pendingGuildCreateVoiceStates.clear();
|
||||||
_listenerAttached = false;
|
_listenerAttached = false;
|
||||||
_client = undefined;
|
_client = undefined;
|
||||||
}
|
}
|
||||||
@@ -73,6 +90,21 @@ export function setVideoRecorderClient(client: Client | undefined) {
|
|||||||
void handleVoiceStateUpdate(oldState, newState);
|
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). */
|
/** 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
|
* 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).
|
* (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
|
* BUG FIX (2026-09-02 — user report "tidak mendeteksi user yg sudah ada"):
|
||||||
* restart because the selfbot's guild member cache hasn't been populated yet.
|
* `channel.members` is empty after a gateway restart because (a) the selfbot
|
||||||
* When that happens, fall back to the Discord REST voice-states endpoint which
|
* library's GUILD_CREATE handler DROPS `d.voice_states` (it only sends
|
||||||
* returns all users currently in the voice channel with their live voice state
|
* GUILD_SUBSCRIPTIONS_BULK, never parses the initial voice state list), and
|
||||||
* (self_video, self_stream, etc.). This ensures we detect pre-existing
|
* (b) guild member cache isn't populated yet. We now capture voice_states
|
||||||
* camera/screen-share users even on a cold start.
|
* 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(
|
export async function scanExistingStreamers(
|
||||||
channel: VoiceChannel,
|
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
|
// 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
|
// 404. The user-token-compatible way to discover who is in the voice
|
||||||
// channel: fetch the guild member list (GET /guilds/{id}/members — used
|
// channel: fetch the guild member list (GET /guilds/{id}/members — used
|
||||||
// elsewhere by the selfbot) which populates member.voice states, then scan
|
// elsewhere by the selfbot) which populates member.voice states, then scan
|
||||||
// `channel.members` again. Fire-and-forget retry: if it still looks empty
|
// `channel.members` again. NOTE: user tokens may also get 403 on this REST
|
||||||
// after one fetch, we give up quietly (voiceStateUpdate will catch anyone
|
// path (verified 2026-09-02) — Path C is the reliable one; Path D is a
|
||||||
// who toggles later).
|
// best-effort fallback for real bot tokens.
|
||||||
if (videoUsers.length === 0 && members && members.size === 0 && _client) {
|
if (videoUsers.length === 0 && members && members.size === 0 && _client) {
|
||||||
try {
|
try {
|
||||||
await channel.guild.members.fetch();
|
await channel.guild.members.fetch();
|
||||||
@@ -181,6 +232,40 @@ export function untrackChannel(guildId: string) {
|
|||||||
stopAllStreamWatches(guildId);
|
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(
|
async function handleVoiceStateUpdate(
|
||||||
_oldState: VoiceState,
|
_oldState: VoiceState,
|
||||||
newState: VoiceState,
|
newState: VoiceState,
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ describe("videoRecorder (stream-watch orchestration)", () => {
|
|||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
setVideoRecorderClient(client);
|
setVideoRecorderClient(client);
|
||||||
setVideoRecorderClient(client); // idempotent (videoRecorder + streamWatch)
|
setVideoRecorderClient(client); // idempotent (videoRecorder + streamWatch)
|
||||||
// one raw (streamWatch) + one voiceStateUpdate (videoRecorder)
|
// one voiceStateUpdate (videoRecorder) + raw GUILD_CREATE (videoRecorder)
|
||||||
expect(client.on).toHaveBeenCalledTimes(2);
|
// + raw STREAM_* (streamWatch) = 3
|
||||||
|
expect(client.on).toHaveBeenCalledTimes(3);
|
||||||
expect(client.on).toHaveBeenCalledWith(
|
expect(client.on).toHaveBeenCalledWith(
|
||||||
"voiceStateUpdate",
|
"voiceStateUpdate",
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
@@ -190,4 +191,74 @@ describe("videoRecorder (stream-watch orchestration)", () => {
|
|||||||
expect(spy).toHaveBeenCalledWith(ch as any, "u-cam");
|
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