diff --git a/services/discord-gateway/src/modules/voice-recording/recorder.ts b/services/discord-gateway/src/modules/voice-recording/recorder.ts index cd90434c..1b46e673 100644 --- a/services/discord-gateway/src/modules/voice-recording/recorder.ts +++ b/services/discord-gateway/src/modules/voice-recording/recorder.ts @@ -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 { + // 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, diff --git a/services/discord-gateway/src/modules/voice-recording/videoRecorder.ts b/services/discord-gateway/src/modules/voice-recording/videoRecorder.ts index cc6917bb..d80729fe 100644 --- a/services/discord-gateway/src/modules/voice-recording/videoRecorder.ts +++ b/services/discord-gateway/src/modules/voice-recording/videoRecorder.ts @@ -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(); /** Channels currently under audio recording, keyed by guildId. */ const watchedChannels = new Map(); +/** Eagerly-established selfbot voice connection per guild (video receive). */ +const selfbotVoices = new Map(); + 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; + } + | 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; - } - | 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. diff --git a/services/discord-gateway/tests/videoRecorder.test.ts b/services/discord-gateway/tests/videoRecorder.test.ts index 53817d05..0ae12f87 100644 --- a/services/discord-gateway/tests/videoRecorder.test.ts +++ b/services/discord-gateway/tests/videoRecorder.test.ts @@ -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(); + }); });