fix(gateway): establish selfbot voice connection eagerly so video (camera/screen-share) capture works
Video capture (camera + screen share) recorded ZERO frames because the selfbot ClientVoiceManager.connection was created LAZILY — only when a user started streaming. At that point the bot is already connected via @discordjs/voice, so the selfbot re-join never gets a fresh VOICE_SERVER_UPDATE and times out with VOICE_CONNECTION_TIMEOUT after 15s. joinStreamConnection (STREAM_WATCH) + receiver.createVideoStream both need that selfbot VoiceConnection CONNECTED. Fix: establish the selfbot VoiceConnection eagerly in recorder.startRecording, BEFORE joinVoiceChannel, so it rides the bot's fresh join (Discord emits VOICE_SERVER_UPDATE → selfbot authenticates). videoRecorder reuses the cached connection per guild, tears it down on voice stop/destroy. Best-effort — never blocks audio recording. Verified: typecheck + build + biome (src/) green; 9/9 videoRecorder tests.
This commit is contained in:
@@ -20,7 +20,11 @@ import {
|
||||
import { createSpeakingHandler } from "./recorder/speakingHandler.js";
|
||||
import { hookScreenShareAudio } from "./screenShareAudio.js";
|
||||
import { hookVideoReceiver } from "./videoReceiver.js";
|
||||
import { trackChannel, untrackChannel } from "./videoRecorder.js";
|
||||
import {
|
||||
ensureSelfbotVoice,
|
||||
trackChannel,
|
||||
untrackChannel,
|
||||
} from "./videoRecorder.js";
|
||||
|
||||
const logger = createChildLogger("recorder");
|
||||
|
||||
@@ -111,6 +115,13 @@ export async function startRecording(
|
||||
client: Client,
|
||||
channel: VoiceChannel,
|
||||
): Promise<VoiceConnection | null> {
|
||||
// Establish the SELFbot voice connection FIRST (video receive). It MUST ride
|
||||
// the bot's FRESH join so Discord emits VOICE_SERVER_UPDATE and the selfbot
|
||||
// VoiceConnection authenticates. (Placing it after the @discordjs/voice join
|
||||
// is Ready — a lazy re-join — times out and video capture fails.) Best-effort,
|
||||
// fire-and-forget so it never blocks the audio join below.
|
||||
void ensureSelfbotVoice(channel).catch(() => {});
|
||||
|
||||
const connection = joinVoiceChannel({
|
||||
channelId: channel.id,
|
||||
guildId: channel.guild.id,
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
* member's H264+Opus RTP to a `Recorder` (ffmpeg over UDP) which muxes to
|
||||
* Matroska (.mkv).
|
||||
*
|
||||
* FAILURE FIX (2026-08-31): the selfbot `VoiceConnection` must be established
|
||||
* EAGERLY at voice-join time. Calling `client.voice.joinChannel()` lazily —
|
||||
* only when a member starts streaming — times out with VOICE_CONNECTION_TIMEOUT
|
||||
* because the bot is already in the channel, so Discord never emits a fresh
|
||||
* VOICE_SERVER_UPDATE and the Selfbot connection never gets token/endpoint.
|
||||
* Establishing it alongside the @discordjs/voice join (on a FRESH join) makes
|
||||
* Discord emit VOICE_SERVER_UPDATE → the selfbot connection authenticates.
|
||||
*
|
||||
* All of this is best-effort: any failure is logged and NEVER breaks the
|
||||
* gateway's existing audio/voice recording.
|
||||
*/
|
||||
@@ -38,12 +46,22 @@ interface WatchHandle {
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** A connected (or in-progress) selfbot voice connection, keyed by guildId. */
|
||||
interface SelfbotVoice {
|
||||
guildId: string;
|
||||
conn: unknown; // selfbot VoiceConnection
|
||||
connectedAt: number;
|
||||
}
|
||||
|
||||
/** Recorders keyed by `${guildId}:${userId}`. */
|
||||
const activeRecorders = new Map<string, WatchHandle>();
|
||||
|
||||
/** Channels currently under audio recording, keyed by guildId. */
|
||||
const watchedChannels = new Map<string, VoiceChannel>();
|
||||
|
||||
/** Eagerly-established selfbot voice connection per guild (video receive). */
|
||||
const selfbotVoices = new Map<string, SelfbotVoice>();
|
||||
|
||||
let _client: Client | undefined;
|
||||
let _listenerAttached = false;
|
||||
|
||||
@@ -54,6 +72,15 @@ export function setVideoRecordingsDir(dir: string) {
|
||||
_recordingsDir = dir;
|
||||
}
|
||||
|
||||
/** Test-only: clear all module-level state (recorders, channels, selfbot conns). */
|
||||
export function __resetVideoRecorderState(): void {
|
||||
activeRecorders.clear();
|
||||
watchedChannels.clear();
|
||||
selfbotVoices.clear();
|
||||
_listenerAttached = false;
|
||||
_client = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selfbot client and register the singleton voiceStateUpdate listener
|
||||
* (idempotent). Called once at gateway bootstrap.
|
||||
@@ -79,6 +106,7 @@ export function trackChannel(guildId: string, channel: VoiceChannel) {
|
||||
export function untrackChannel(guildId: string) {
|
||||
watchedChannels.delete(guildId);
|
||||
stopAllVideoRecordings(guildId);
|
||||
destroyGuildSelfbotVoice(guildId);
|
||||
}
|
||||
|
||||
async function handleVoiceStateUpdate(
|
||||
@@ -110,6 +138,115 @@ async function handleVoiceStateUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve (or create) the selfbot `VoiceConnection` for a guild, best-effort.
|
||||
*
|
||||
* Establishes `client.voice.joinChannel(channel)` — the selfbot lib's own
|
||||
* VoiceConnection, which has `.receiver.createVideoStream` and
|
||||
* `.joinStreamConnection`. Cached per guild. Returns the connection's
|
||||
* `{ conn, status }` or null on failure.
|
||||
*
|
||||
* IMPORTANT ordering: this MUST run while the bot is freshly joining the
|
||||
* channel (alongside @discordjs/voice's join), so Discord emits VOICE_SERVER_UPDATE
|
||||
* and the selfbot connection authenticates. Do NOT call it lazily after the bot
|
||||
* is already connected — that times out (VOICE_CONNECTION_TIMEOUT).
|
||||
*/
|
||||
export async function ensureSelfbotVoice(
|
||||
channel: VoiceChannel,
|
||||
): Promise<{ conn: unknown; status: number } | null> {
|
||||
try {
|
||||
if (!_client) {
|
||||
logger.debug("ensureSelfbotVoice: no client set");
|
||||
return null;
|
||||
}
|
||||
const guildId = channel.guild?.id;
|
||||
if (!guildId) return null;
|
||||
|
||||
// Reuse an already-connected selfbot connection.
|
||||
const existing = selfbotVoices.get(guildId);
|
||||
if (existing && getVoiceStatus(existing.conn) === 0 /* CONNECTED */) {
|
||||
return { conn: existing.conn, status: 0 };
|
||||
}
|
||||
|
||||
const voiceManager = (_client as unknown as { voice?: unknown }).voice as
|
||||
| {
|
||||
joinChannel?: (
|
||||
ch: unknown,
|
||||
cfg?: {
|
||||
selfMute?: boolean;
|
||||
selfDeaf?: boolean;
|
||||
selfVideo?: boolean;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!voiceManager?.joinChannel) {
|
||||
logger.warn(
|
||||
{ guildId },
|
||||
"ensureSelfbotVoice: selfbot voice manager unavailable",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ guildId, channelId: channel.id },
|
||||
"Establishing selfbot voice connection (video receive)",
|
||||
);
|
||||
const conn = await voiceManager.joinChannel(channel, {
|
||||
selfMute: false,
|
||||
selfDeaf: false,
|
||||
selfVideo: false,
|
||||
});
|
||||
if (!conn) return null;
|
||||
|
||||
const status = getVoiceStatus(conn);
|
||||
selfbotVoices.set(guildId, { guildId, conn, connectedAt: Date.now() });
|
||||
logger.info(
|
||||
{ guildId, channelId: channel.id, status },
|
||||
status === 0
|
||||
? "Selfbot voice connected (video receive ready)"
|
||||
: "Selfbot voice connection pending",
|
||||
);
|
||||
return { conn, status };
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
guildId: channel.guild?.id,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"ensureSelfbotVoice failed (best-effort, ignoring)",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear down the cached selfbot voice connection for a guild. */
|
||||
export function destroyGuildSelfbotVoice(guildId: string): void {
|
||||
const entry = selfbotVoices.get(guildId);
|
||||
if (!entry) return;
|
||||
selfbotVoices.delete(guildId);
|
||||
try {
|
||||
const conn = entry.conn as unknown as {
|
||||
disconnect?: () => void;
|
||||
destroy?: () => void;
|
||||
};
|
||||
conn.disconnect?.();
|
||||
logger.info({ guildId }, "Destroyed selfbot voice connection");
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ guildId, err: err instanceof Error ? err.message : String(err) },
|
||||
"Error destroying selfbot voice connection",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the VoiceStatus number off a selfbot VoiceConnection (0 = CONNECTED). */
|
||||
function getVoiceStatus(conn: unknown): number {
|
||||
const status = (conn as { status?: number } | null)?.status;
|
||||
return typeof status === "number" ? status : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin recording the video (camera / screen share) of `userId` in `channel`.
|
||||
* Returns the watch handle on success, or null on any failure.
|
||||
@@ -129,31 +266,10 @@ export async function startVideoRecording(
|
||||
const key = `${channel.guild.id}:${userId}`;
|
||||
if (activeRecorders.has(key)) return activeRecorders.get(key) ?? null;
|
||||
|
||||
const voiceManager = (_client as unknown as { voice?: unknown }).voice as
|
||||
| {
|
||||
joinChannel?: (
|
||||
ch: unknown,
|
||||
cfg?: {
|
||||
selfMute?: boolean;
|
||||
selfDeaf?: boolean;
|
||||
selfVideo?: boolean;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!voiceManager?.joinChannel) {
|
||||
logger.warn("Video recorder: selfbot voice manager unavailable");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Selfbot voice connection — joinChannel is idempotent (reuses
|
||||
// ClientVoiceManager.connection, re-confirms the same session).
|
||||
const voiceConn = await voiceManager.joinChannel(channel, {
|
||||
selfMute: false,
|
||||
selfDeaf: false,
|
||||
selfVideo: false,
|
||||
});
|
||||
// Use the eagerly-established selfbot connection (falls back to a lazy
|
||||
// joinChannel as a last resort — may time out if the bot already joined).
|
||||
const eager = await ensureSelfbotVoice(channel);
|
||||
const voiceConn: unknown | null = eager?.conn ?? null;
|
||||
if (!voiceConn) return null;
|
||||
|
||||
// 2. STREAM_WATCH handshake.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetVideoRecorderState,
|
||||
destroyGuildSelfbotVoice,
|
||||
ensureSelfbotVoice,
|
||||
setVideoRecorderClient,
|
||||
setVideoRecordingsDir,
|
||||
startVideoRecording,
|
||||
@@ -14,14 +17,17 @@ function makeVoiceManager() {
|
||||
on: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
const watchConn = { sendSignalScreenshare: vi.fn(async () => {}) };
|
||||
const voiceConn = {
|
||||
status: 0, // VoiceStatus.CONNECTED
|
||||
disconnect: vi.fn(),
|
||||
receiver: {
|
||||
createVideoStream: vi.fn(() => recorder),
|
||||
},
|
||||
joinStreamConnection: vi.fn(async () => watchConn),
|
||||
};
|
||||
const watchConn = { sendSignalScreenshare: vi.fn(async () => {}) };
|
||||
return { voiceConn, watchConn, recorder, joinChannel: async () => voiceConn };
|
||||
const joinChannel = vi.fn(async () => voiceConn);
|
||||
return { voiceConn, watchConn, recorder, joinChannel };
|
||||
}
|
||||
|
||||
function makeChannel(guildId = "g1", channelId = "c1") {
|
||||
@@ -46,6 +52,7 @@ function makeUser() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetVideoRecorderState();
|
||||
setVideoRecordingsDir("/tmp/gmw-vidrec-test");
|
||||
});
|
||||
|
||||
@@ -117,4 +124,38 @@ describe("videoRecorder", () => {
|
||||
untrackChannel("g1");
|
||||
expect(client.voice.recorder.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ensureSelfbotVoice establishes and caches the selfbot connection (join once)", async () => {
|
||||
const client = makeClient();
|
||||
setVideoRecorderClient(client);
|
||||
const ch = makeChannel();
|
||||
const r1 = await ensureSelfbotVoice(ch);
|
||||
expect(r1?.status).toBe(0);
|
||||
expect(client.voice.joinChannel).toHaveBeenCalledTimes(1);
|
||||
// Second call reuses the cached CONNECTED connection — no re-join.
|
||||
const r2 = await ensureSelfbotVoice(makeChannel());
|
||||
expect(client.voice.joinChannel).toHaveBeenCalledTimes(1);
|
||||
expect(r2?.status).toBe(0);
|
||||
});
|
||||
|
||||
it("untrackChannel destroys the cached selfbot voice connection", async () => {
|
||||
const client = makeClient();
|
||||
setVideoRecorderClient(client);
|
||||
await ensureSelfbotVoice(makeChannel("g1", "c1"));
|
||||
const other = makeClient();
|
||||
setVideoRecorderClient(other);
|
||||
await ensureSelfbotVoice(makeChannel("g2", "c2"));
|
||||
untrackChannel("g1");
|
||||
expect(client.voice.voiceConn.disconnect).toHaveBeenCalled();
|
||||
// g2 connection untouched by g1 teardown
|
||||
expect(other.voice.voiceConn.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("destroyGuildSelfbotVoice disconnects the selfbot connection", async () => {
|
||||
const client = makeClient();
|
||||
setVideoRecorderClient(client);
|
||||
await ensureSelfbotVoice(makeChannel());
|
||||
destroyGuildSelfbotVoice("g1");
|
||||
expect(client.voice.voiceConn.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user