Compare commits
2
Commits
cee33c0d1f
...
bc66babd45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc66babd45 | ||
|
|
ac32179bb1 |
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,41 @@ 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) return;
|
||||||
|
// DIAG: log every raw event type once until we see GUILD_CREATE shape.
|
||||||
|
if (
|
||||||
|
p.t === "GUILD_CREATE" ||
|
||||||
|
p.t === "READY" ||
|
||||||
|
p.t === "GUILD_MEMBERS_CHUNK"
|
||||||
|
) {
|
||||||
|
const d = p.d as Record<string, unknown>;
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
rawType: p.t,
|
||||||
|
hasVoiceStates: Array.isArray(d?.voice_states),
|
||||||
|
voiceStatesCount: Array.isArray(d?.voice_states)
|
||||||
|
? (d.voice_states as unknown[]).length
|
||||||
|
: -1,
|
||||||
|
dKeys: Object.keys(d ?? {}).slice(0, 10),
|
||||||
|
},
|
||||||
|
"RAW diag: GUILD_CREATE/READY/GUILD_MEMBERS_CHUNK",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (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 +144,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 +171,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 +252,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