diff --git a/bot/src/config.ts b/bot/src/config.ts index 17950cc..1b02dfb 100644 --- a/bot/src/config.ts +++ b/bot/src/config.ts @@ -46,6 +46,11 @@ export const config = { // selfbot backend (ToS-risk; use a throwaway account token, never your main) selfbotToken: opt("DISCORD_SELFBOT_TOKEN"), + // How long to wait for the Go-Live stream WebRTC to actually reach + // "connected" before declaring the broadcast failed (and tearing the local + // ffmpeg pipeline down). Guards against reporting "live" when nothing reaches + // Discord. + streamReadyTimeoutMs: parseInt(opt("STREAM_READY_TIMEOUT_MS", "25000"), 10), // Use NVENC hardware encode + hw-accelerated decode for the stream (RTX 5050). streamHw: opt("STREAM_HW", "1") !== "0", // Capture desktop audio into the broadcast so the stream has sound. Pulls the diff --git a/bot/src/stream/selfbot.test.ts b/bot/src/stream/selfbot.test.ts index 72db9f3..e33fa83 100644 --- a/bot/src/stream/selfbot.test.ts +++ b/bot/src/stream/selfbot.test.ts @@ -33,6 +33,11 @@ test("a self-ended stream tears down the capture pipeline (no ffmpeg leak)", asy } async joinVoice() {} leaveVoice = leaveVoice; + // The Go-Live readiness signal start() now waits on: the stream + // connection's WebRTC reaching "connected". + get voiceConnection() { + return { streamConnection: { webRtcConn: { ready: true } } }; + } }, prepareStream: () => ({ command: { on() {} }, output: {} }), playStream: () => playPromise, @@ -46,6 +51,7 @@ test("a self-ended stream tears down the capture pipeline (no ffmpeg leak)", asy vncFramerate: 60, vncBitrateKbps: 8000, streamHw: true, + streamReadyTimeoutMs: 2000, } as any); await s.start({ guildId: "g", voiceChannelId: "v" } as any); @@ -59,3 +65,57 @@ test("a self-ended stream tears down the capture pipeline (no ffmpeg leak)", asy expect(leaveVoice).toHaveBeenCalled(); // voice connection released expect(s.isActive()).toBe(false); }, 30000); + +test("a Go-Live that never connects is reported as failed, not live, and is torn down", async () => { + const kill = mock(() => {}); + const leaveVoice = mock(() => {}); + const destroy = mock(() => {}); + + // playStream never resolves (the stream is "running" locally) but the WebRTC + // never reaches "connected" -> start() must time out rather than claim live. + const neverEnds = new Promise(() => {}); + + mock.module("node:child_process", () => ({ + spawn: () => ({ stdout: {}, stderr: { on() {} }, kill }), + })); + mock.module("discord.js-selfbot-v13", () => ({ + Client: class { + destroy = destroy; + async login() {} + }, + })); + mock.module("@dank074/discord-video-stream", () => ({ + Streamer: class { + client: any; + constructor(c: any) { + this.client = c; + } + async joinVoice() {} + leaveVoice = leaveVoice; + // Stream connection exists but its WebRTC never connects. + get voiceConnection() { + return { streamConnection: { webRtcConn: { ready: false } } }; + } + }, + prepareStream: () => ({ command: { on() {} }, output: {} }), + playStream: () => neverEnds, + })); + + const { SelfbotStreamer } = await import("./selfbot.ts"); + const s = new SelfbotStreamer({ + selfbotToken: "token", + vncDisplay: ":1", + vncResolution: "1920x1080", + vncFramerate: 60, + vncBitrateKbps: 8000, + streamHw: true, + streamReadyTimeoutMs: 800, + } as any); + + const msg = await s.start({ guildId: "g", voiceChannelId: "v" } as any); + + expect(s.isActive()).toBe(false); // never claimed live + expect(msg).toContain("방송을 시작하지 못했습니다"); // explicit failure status + expect(kill).toHaveBeenCalled(); // local ffmpeg torn down on failure + expect(leaveVoice).toHaveBeenCalled(); +}, 30000); diff --git a/bot/src/stream/selfbot.ts b/bot/src/stream/selfbot.ts index 24a0ec3..d950ca1 100644 --- a/bot/src/stream/selfbot.ts +++ b/bot/src/stream/selfbot.ts @@ -28,12 +28,56 @@ export class SelfbotStreamer implements ScreenStreamer { private keepalive: VncKeepalive | null = null; private helper: ChildProcess | null = null; private controller: AbortController | null = null; - private active = false; + // `starting` is the in-flight lock (start() is mid-handshake); `live` is the + // REAL Go-Live state — true only once Discord's stream WebRTC actually reaches + // "connected". isActive() reports `live` so the brain's search routing and the + // auto-start retry react to whether we are genuinely broadcasting, not to a + // local "we kicked off ffmpeg" guess. + private starting = false; + private live = false; constructor(private config: AppConfig) {} isActive() { - return this.active; + return this.live; + } + + /** How long to wait for the Go-Live stream WebRTC to reach "connected". */ + private get streamReadyTimeoutMs(): number { + return (this.config as any).streamReadyTimeoutMs ?? 25_000; + } + + /** + * Poll the streaming library for the REAL Go-Live readiness signal: the + * stream connection's WebRTC peer reaching "connected" state (set only after + * STREAM_CREATE -> STREAM_SERVER_UPDATE -> protocol/DAVE handshake). Resolves + * true when live, false on timeout or abort. + */ + private async waitStreamReady(streamer: any, signal: AbortSignal, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (signal.aborted) return false; + if (streamer?.voiceConnection?.streamConnection?.webRtcConn?.ready === true) return true; + await new Promise((r) => setTimeout(r, 250)); + } + return false; + } + + /** A compact snapshot of the connection state, logged when readiness times out. */ + private streamDiag(streamer: any): string { + const vc = streamer?.voiceConnection; + const sc = vc?.streamConnection; + try { + return JSON.stringify({ + voiceReady: vc?.webRtcConn?.ready ?? null, + voiceDave: vc?.daveReady ?? null, + streamConnCreated: !!sc, + streamReady: sc?.webRtcConn?.ready ?? null, + streamDave: sc?.daveReady ?? null, + }); + } catch { + return "{unavailable}"; + } } /** @@ -79,7 +123,7 @@ export class SelfbotStreamer implements ScreenStreamer { } async start(ctx: StreamContext): Promise { - if (this.active) return "이미 송출 중입니다."; + if (this.live || this.starting) return "이미 송출 중이거나 시작 중입니다."; // Screen-share gate: STREAM_BROWSER=false means voice + API/MCP only, so we // never go Live. Enforced HERE (not just in the slash command) so every // caller - including stream-hold.ts - respects it. @@ -93,12 +137,14 @@ export class SelfbotStreamer implements ScreenStreamer { return "셀프봇 송출은 음성 채널 안에서 호출해야 합니다."; } // Lock the starting state BEFORE any await: the human-pause delays below - // mean start() is in-flight for several seconds, so a second /stream call - // must be rejected by the `this.active` guard above, and the status must - // read "starting" rather than idle during the wait. Keep controller / - // streamer / capture as LOCAL refs so an interleaved stop() (which nulls the - // instance fields) can't turn our own continuation into a null dereference. - this.active = true; + // mean start() is in-flight for several seconds, so a second start() call + // must be rejected by the guard above, and the status must read "starting" + // rather than live during the wait. `live` is only set once the stream + // WebRTC is actually connected (see the readiness wait below). Keep + // controller / streamer / capture as LOCAL refs so an interleaved stop() + // (which nulls the instance fields) can't turn our own continuation into a + // null dereference. + this.starting = true; const controller = (this.controller = new AbortController()); const signal = controller.signal; let streamer: any = null; @@ -275,9 +321,47 @@ export class SelfbotStreamer implements ScreenStreamer { if (this.helper === helper) this.helper = null; if (this.streamer === streamer) this.streamer = null; this.controller = null; - this.active = false; + this.live = false; + this.starting = false; }); + // Wait for the REAL Go-Live signal before declaring success. playStream + // above only kicks off the pipeline (it resolves when the stream ENDS, not + // when it connects), so without this we'd report "live" while the WebRTC + // handshake is still pending — or never completes. Poll the library's + // stream connection until its WebRTC reaches "connected". + const ready = await this.waitStreamReady(streamer, signal, this.streamReadyTimeoutMs); + if (!ready) { + // Not actually broadcasting: log why and tear the pipeline down NOW so a + // dead x11grab/NVENC encoder isn't left pinning the GPU/CPU. We abort and + // do the teardown with our LOCAL refs here rather than relying on the + // playStream .finally (it early-returns once we null this.controller). + console.error( + `[selfbot] ⚠️ Go-Live did NOT connect within ${this.streamReadyTimeoutMs}ms — ` + + `not broadcasting. state=${this.streamDiag(streamer)}`, + ); + controller.abort(); + try { capture?.kill("SIGKILL"); } catch { /* ignore */ } + try { keepalive?.stop(); } catch { /* ignore */ } + try { helper?.kill(); } catch { /* ignore */ } + try { + streamer?.leaveVoice?.(); + streamer?.client?.destroy?.(); + } catch { /* ignore */ } + if (this.controller === controller) { + if (this.capture === capture) this.capture = null; + if (this.keepalive === keepalive) this.keepalive = null; + if (this.helper === helper) this.helper = null; + if (this.streamer === streamer) this.streamer = null; + this.controller = null; + this.live = false; + this.starting = false; + } + return `방송을 시작하지 못했습니다 (Go-Live 연결 타임아웃 ${this.streamReadyTimeoutMs}ms). 동일 계정 음성 충돌 또는 송출 경로 문제로 보입니다.`; + } + this.live = true; + this.starting = false; + console.log("🔴 [selfbot] Go-Live WebRTC connected — broadcasting for real."); return "🔴 셀프봇으로 VNC 화면을 음성채널에 실시간 송출 중입니다 (Go Live)."; } catch (e) { // Startup was aborted (stop() during a pause) or failed. Tear down using @@ -306,7 +390,7 @@ export class SelfbotStreamer implements ScreenStreamer { } // Only release the lock / clear instance state if WE are still the // current attempt. If a concurrent stop()+start() already replaced the - // controller, a newer start() owns `active` — clearing it here would + // controller, a newer start() owns the lock — clearing it here would // unlock it mid-startup and let a third start() race in. if (this.controller === controller) { if (this.capture === capture) this.capture = null; @@ -314,7 +398,8 @@ export class SelfbotStreamer implements ScreenStreamer { if (this.helper === helper) this.helper = null; if (this.streamer === streamer) this.streamer = null; this.controller = null; - this.active = false; + this.live = false; + this.starting = false; } if (signal.aborted) return "송출을 시작하는 중에 중지했습니다."; throw e; @@ -349,6 +434,7 @@ export class SelfbotStreamer implements ScreenStreamer { /* ignore */ } this.streamer = null; - this.active = false; + this.live = false; + this.starting = false; } }