fix(video): detect pre-existing voice members after gateway restart

Root cause: scanExistingStreamers only read channel.members, which is
EMPTY after a gateway restart because the selfbot's guild member cache
hasn't been populated yet. So any user who was ALREADY on camera /
screen-sharing when the bot (re)joined was never detected → no video.

Fix: when the member cache is empty, fall back to guild.members.fetch()
(REST GET /guilds/{id}/members — user-token compatible; the bot-only
GET /channels/{id}/voice-states returns 404 for selfbots, verified live)
which populates member.voice states, then re-scan channel.members for
streaming/selfVideo.

- scanExistingStreamers is now async; trackChannel fire-and-forgets it
- logs source=cache vs source=rest-members for observability
- +1 test: cold-start channel.members empty → REST fetch → watch camera user
- 191/191 tests pass, typecheck + build + biome clean
This commit is contained in:
asepharyana
2026-09-02 01:13:50 +07:00
parent dd9f2f6bbc
commit cee33c0d1f
2 changed files with 116 additions and 18 deletions
@@ -82,31 +82,91 @@ export function trackChannel(guildId: string, channel: VoiceChannel) {
// share / camera on BEFORE the bot joined), no voiceStateUpdate with
// streaming:true fires for them. Scan every member now and start watching
// anyone already streaming, so we don't miss a live stream in progress.
scanExistingStreamers(channel);
// Fire-and-forget: REST fallback may be needed if guild cache is cold.
void scanExistingStreamers(channel);
}
/**
* Scan all members currently in `channel` and start watching anyone whose
* voice state has `streaming` set (screen share / camera already on when the
* bot joined). Fire-and-forget per member — idempotent (startStreamWatch is
* a no-op if a watch already exists for that user).
* voice state has `streaming` or `self_video` set (screen share / camera
* 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.
*/
export function scanExistingStreamers(channel: VoiceChannel): void {
export async function scanExistingStreamers(
channel: VoiceChannel,
): Promise<void> {
const selfId = _client?.user?.id;
const videoUsers: { userId: string; source: string }[] = [];
// ── Path A: guild cache has members ────────────────────────────────────
const members = channel.members;
if (!members || members.size === 0) return;
let watched = 0;
for (const [, member] of members) {
// Watch anyone with video ACTIVE: screen share (streaming) OR camera
// (selfVideo). Camera on Discord (mobile/desktop "video") is reported via
// self_video:true, NOT self_stream — previously only streaming was checked
// so camera-only users were never watched.
const vs = member.voice;
const hasVideo = Boolean(vs?.streaming || vs?.selfVideo);
if (hasVideo && member.id !== _client?.user?.id) {
startVideoRecording(channel, member.id);
watched++;
if (members && members.size > 0) {
for (const [, member] of members) {
if (member.id === selfId) continue;
const vs = member.voice;
const hasVideo = Boolean(vs?.streaming || vs?.selfVideo);
if (hasVideo) {
videoUsers.push({ userId: member.id, source: "cache" });
}
}
}
// ── Path B: 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).
if (videoUsers.length === 0 && members && members.size === 0 && _client) {
try {
await channel.guild.members.fetch();
// Re-read members now that cache is warm.
const warmMembers = channel.members;
if (warmMembers && warmMembers.size > 0) {
for (const [, member] of warmMembers) {
if (member.id === selfId) continue;
const vs = member.voice;
const hasVideo = Boolean(vs?.streaming || vs?.selfVideo);
if (hasVideo) {
videoUsers.push({ userId: member.id, source: "rest-members" });
}
}
}
logger.info(
{
channelId: channel.id,
fetchedMembers: warmMembers?.size ?? 0,
foundVideo: videoUsers.length,
},
"Fetched guild members via REST for pre-existing video detection",
);
} catch (err) {
logger.warn(
{
channelId: channel.id,
err: err instanceof Error ? err.message : String(err),
},
"Failed to fetch guild members via REST — video detection may miss pre-existing users",
);
}
}
// ── Start watching detected video users ────────────────────────────────
let watched = 0;
for (const { userId, source } of videoUsers) {
startVideoRecording(channel, userId);
watched++;
logger.info({ userId, source }, "Detected pre-existing video user on join");
}
if (watched > 0) {
logger.info(
{ guildId: channel.guild.id, watched },
@@ -14,7 +14,10 @@ import {
function makeChannel(guildId = "g1", channelId = "c1") {
return {
id: channelId,
guild: { id: guildId },
guild: {
id: guildId,
members: { fetch: vi.fn().mockResolvedValue(new Map()) },
},
};
}
@@ -152,4 +155,39 @@ describe("videoRecorder (stream-watch orchestration)", () => {
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");
});
});
});