/** * Selfbot live-stream backend (default). * * Streams the VNC X display (:1) into the voice channel as a real Discord * "Go Live" broadcast. Discord blocks video from *bot* accounts, so this path * requires a USER account token (a "selfbot"), which violates Discord ToS and * can get the account banned. Use a throwaway/burner account, never your main. * * Dependencies are optional (native) and dynamically imported so the core bot * installs/runs without them: * bun add discord.js-selfbot-v13 @dank074/discord-video-stream * bun pm trust @dank074/node-av node-datachannel # build native deps * * API targets @dank074/discord-video-stream v6 (verified against its d.ts): * new Streamer(client) -> joinVoice(guildId, channelId) * prepareStream(input, opts, signal) -> { command, output } * playStream(output, streamer, { type: "go-live" }, signal) */ import { spawn, type ChildProcess } from "node:child_process"; import type { AppConfig } from "../config.ts"; import type { ScreenStreamer, StreamContext } from "./index.ts"; import { VncKeepalive, resolveVncPassword, vncPortForDisplay } from "./vnc-keepalive.ts"; export class SelfbotStreamer implements ScreenStreamer { readonly kind = "selfbot" as const; private streamer: any = null; private capture: ChildProcess | null = null; private keepalive: VncKeepalive | null = null; private helper: ChildProcess | null = null; private controller: AbortController | null = null; // `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; // Whether WE own the streamer's client (dedicated/separate account). When we // ride the conversation's shared session we must NOT destroy its client or // leave its voice on teardown — only stop the Go-Live stream. private ownsClient = false; /** Tear down the streamer's Discord side: full leave+destroy when we own the * client, otherwise just stop the Go-Live stream (the conversation owns the * client/voice). */ private teardownStreamer(streamer: any): void { try { if (this.ownsClient) { streamer?.leaveVoice?.(); streamer?.client?.destroy?.(); } else { streamer?.stopStream?.(); } } catch { /* ignore */ } } constructor(private config: AppConfig) {} isActive() { 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}"; } } /** * Wait a randomised, human-plausible amount of time. Resolves immediately if * the stream is aborted (stop()) mid-wait, so teardown never hangs on a pause. */ private humanPause(minMs: number, maxMs: number, signal?: AbortSignal): Promise { const ms = Math.floor(minMs + Math.random() * Math.max(0, maxMs - minMs)); return new Promise((resolve) => { if (signal?.aborted) return resolve(); const onAbort = () => { clearTimeout(timer); resolve(); }; const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); signal?.addEventListener("abort", onAbort, { once: true }); }); } private async loadLib() { let selfbot: any, vs: any; try { selfbot = await import("discord.js-selfbot-v13"); // Optional native dep; resolved at runtime only. // @ts-ignore - optional dependency, may be absent until `bun add`ed vs = await import("@dank074/discord-video-stream"); } catch (e) { throw new Error( "셀프봇 송출 의존성이 없습니다. 설치: bun add discord.js-selfbot-v13 @dank074/discord-video-stream\n" + `원본 오류: ${(e as Error).message}`, ); } if (!vs.Streamer || !vs.prepareStream || !vs.playStream) { throw new Error( "@dank074/discord-video-stream v6 API(Streamer/prepareStream/playStream)를 찾지 못했습니다. " + "package.json 버전을 ^6.0.0으로 맞추세요.", ); } return { selfbot, vs }; } async start(ctx: StreamContext): Promise { 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. if (this.config.screenBrowser === false) { return "화면 공유(브라우저) 모드가 꺼져 있습니다 (STREAM_BROWSER=false). 음성 + API/MCP 모드로만 동작합니다."; } // Broadcast account: a dedicated DISCORD_STREAM_TOKEN if provided, otherwise // reuse the conversation's selfbot token. In userbot mode (no normal-bot // token) sharing one account fails — the broadcaster's voice connection // can't establish alongside the conversation's, so the Go-Live never // connects. Warn loudly in that case so the failure is self-explanatory. const streamToken = this.config.streamToken || this.config.selfbotToken; if (!streamToken) { return "DISCORD_SELFBOT_TOKEN(또는 DISCORD_STREAM_TOKEN)이 설정되지 않았습니다 (.env). 버너 계정 토큰을 넣어주세요."; } const dedicatedStreamAccount = !!this.config.streamToken && this.config.streamToken !== this.config.selfbotToken; // Single-account userbot: broadcast on the conversation's OWN session — the // Go-Live stream is a SEPARATE stream connection on the same session (exactly // like a real Discord client), so it never deafens or fights the conversation // voice. Only when there is neither a shared session nor a dedicated account // is the broadcast impossible: a second login would deafen the conversation, // so refuse to protect it. const shared = ctx.getSharedSession?.() ?? null; if (!shared && !dedicatedStreamAccount && !this.config.botToken) { console.warn( "[selfbot] ⚠️ 공유 세션도 방송 전용 토큰도 없어 방송을 시작하지 않습니다 (대화 음성 보호).", ); return "방송을 시작할 수 없습니다 (대화 세션 공유 불가 + 전용 계정 없음)."; } if (!ctx.voiceChannelId) { return "셀프봇 송출은 음성 채널 안에서 호출해야 합니다."; } // Lock the starting state BEFORE any await: the human-pause delays below // 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; let capture: ChildProcess | null = null; let keepalive: VncKeepalive | null = null; let helper: ChildProcess | null = null; try { const { selfbot, vs } = await this.loadLib(); const { Streamer, prepareStream, playStream } = vs; signal.throwIfAborted(); if (shared) { // Ride the conversation's existing session: create the Go-Live STREAM // connection on it (playStream below calls createStream against this // voiceConnection) — no second login / voice join, which would deafen or // fight the conversation. type/botId/session_id are required by the // stream-create signalling. this.ownsClient = false; streamer = this.streamer = new Streamer(shared.client); (streamer as any)._voiceConnection = { guildId: shared.guildId, channelId: shared.channelId, session_id: shared.sessionId, type: "guild", botId: shared.botId, }; } else { // Dedicated/separate account: log in and join voice ourselves. Act like // a person, not a bot: breathe after coming online before joining, then // settle before going Live. Randomised; throwIfAborted() after each pause // unwinds into the catch if stop() lands mid-wait. this.ownsClient = true; streamer = this.streamer = new Streamer(new selfbot.Client()); await streamer.client.login(streamToken); signal.throwIfAborted(); await this.humanPause(2500, 4500, signal); signal.throwIfAborted(); await streamer.joinVoice(ctx.guildId, ctx.voiceChannelId); await this.humanPause(6000, 10000, signal); signal.throwIfAborted(); } const [w, h] = this.config.vncResolution.split("x").map((n) => parseInt(n, 10)); // Capture the VNC X display with the SYSTEM ffmpeg (which reliably has // x11grab), then pipe that stream into the library. Relying on the lib's // bundled libav for the x11grab input device is not portable; piping the // system ffmpeg is. (Verified live against a real voice channel.) // // The SYSTEM ffmpeg produces the final, Discord-ready H264 in one pass: // target bitrate (-b:v/-maxrate), no B-frames (WebRTC requires this), a // 1s keyframe interval, and yuv420p. The library then only REMUXES it // (noTranscoding below) so there is no second decode/scale/encode. With // streamHw on (default) this single encode runs on the GPU (h264_nvenc, // RTX 5050); otherwise it falls back to software x264. const hw = this.config.streamHw; const kbps = this.config.vncBitrateKbps; // The library advertises a hardcoded max_bitrate of 10 Mbps to Discord // (BaseMediaConnection: `max_bitrate: 10000 * 1000`). If the encoder bursts // above that negotiated ceiling, WebRTC congestion control drops packets // and the viewer sees stutter. Cap -maxrate at 10 Mbps to stay within it. const LIB_MAX_BITRATE_KBPS = 10000; const maxKbps = Math.min(Math.round(kbps * 1.5), LIB_MAX_BITRATE_KBPS); const captureCodecArgs = hw ? ["-c:v", "h264_nvenc", "-preset", "p4", "-tune", "ll", "-forced-idr", "1"] : ["-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency"]; // Optionally pull desktop audio (the default sink's PipeWire/Pulse monitor) // so the broadcast has sound. We add it as a second input and mux AAC into // the mpegts; the library re-encodes it to Opus for Discord. ffmpeg needs // XDG_RUNTIME_DIR (inherited) to reach the pulse socket. -map is required // once there are two inputs. const audioOn = this.config.streamAudio; const audioInput = audioOn ? ["-f", "pulse", "-i", this.config.streamAudioSource] : []; const audioMap = audioOn ? ["-map", "0:v:0", "-map", "1:a:0"] : []; const audioCodec = audioOn ? ["-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-ac", "2"] : []; capture = this.capture = spawn("ffmpeg", [ "-loglevel", "error", "-thread_queue_size", "1024", "-f", "x11grab", "-framerate", String(this.config.vncFramerate), "-video_size", this.config.vncResolution, "-i", this.config.vncDisplay, ...(audioOn ? ["-thread_queue_size", "1024"] : []), ...audioInput, ...audioMap, ...captureCodecArgs, "-b:v", `${kbps}k`, "-maxrate", `${maxKbps}k`, "-bufsize", `${kbps}k`, "-bf", "0", "-pix_fmt", "yuv420p", "-g", String(this.config.vncFramerate), ...audioCodec, "-f", "mpegts", "pipe:1", ]); capture.stderr?.on("data", (d) => { if (!signal.aborted) console.error("[selfbot x11grab]", d.toString().trim()); }); // Keep a VNC client attached for the life of the stream. TigerVNC only // flushes its framebuffer at full rate while a client pulls updates; the // Discord broadcast reads that framebuffer with x11grab (not as a VNC // client), so without this the captured screen would idle at ~1.5 fps and // the stream would look badly choppy. Fail-open: a missing password just // skips it. Matched to the stream framerate so motion stays smooth. const vncPw = resolveVncPassword(); if (vncPw) { keepalive = this.keepalive = new VncKeepalive({ host: "127.0.0.1", port: vncPortForDisplay(this.config.vncDisplay), password: vncPw, fps: this.config.vncFramerate, }); keepalive.start(); } // Browser-side broadcast defaults (ad-skip, subtitle rule, fullscreen // toolbar hiding) run in a small CDP helper tied to the stream lifecycle. // It attaches to the on-screen Chrome (CDP_PORT) and fail-opens if Chrome // or its deps are absent, so it can never break the stream. try { const helperPath = new URL("../../scripts/stream-test/broadcast-helper.mjs", import.meta.url).pathname; helper = this.helper = spawn("node", [helperPath], { env: { ...process.env, CDP_PORT: process.env.CDP_PORT ?? "9222" }, stdio: "ignore", }); helper.on("error", () => { /* node/playwright missing: the stream runs without the helper */ }); } catch { /* ignore */ } const { command, output } = prepareStream( capture.stdout, { // The capture above is already a Discord-ready H264 elementary stream, // so the library only remuxes it (no second encode). width/height/ // frameRate are passed for signalling; encoding options are ignored // on the copy path. width: w || 1920, height: h || 1080, frameRate: this.config.vncFramerate, videoCodec: "H264", noTranscoding: true, }, signal, ); command.on("error", (err: Error) => { if (!signal.aborted) console.error("[selfbot] ffmpeg error:", err); }); signal.throwIfAborted(); playStream(output, streamer, { type: "go-live" }, signal) .catch((err: Error) => { if (!signal.aborted) console.error("[selfbot] playStream:", err); }) .finally(() => { // The stream ended on its own (Discord closed the Go-Live, the voice // UDP dropped, or ffmpeg exited) rather than via stop(). If we are // still the current attempt, tear the pipeline DOWN: kill the capture // ffmpeg and leave voice. Otherwise the x11grab->nvenc encoder keeps // running forever feeding a pipe nobody reads, pinning a CPU core // while no media is actually transmitted. Skip if a concurrent // stop()/start() already replaced the controller (it owns teardown). if (this.controller !== controller) return; try { capture?.kill("SIGKILL"); } catch { /* ignore */ } try { keepalive?.stop(); } catch { /* ignore */ } try { helper?.kill(); } catch { /* ignore */ } this.teardownStreamer(streamer); 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; }); // 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 */ } this.teardownStreamer(streamer); 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 // our LOCAL refs, then clear instance state only if it still points at us // (a concurrent stop()/start() may already have replaced it). try { capture?.kill("SIGKILL"); } catch { /* ignore */ } try { keepalive?.stop(); } catch { /* ignore */ } try { helper?.kill(); } catch { /* ignore */ } this.teardownStreamer(streamer); // 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 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; 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; } if (signal.aborted) return "송출을 시작하는 중에 중지했습니다."; throw e; } } async stop(): Promise { this.controller?.abort(); this.controller = null; try { this.capture?.kill("SIGKILL"); } catch { /* ignore */ } this.capture = null; try { this.keepalive?.stop(); } catch { /* ignore */ } this.keepalive = null; try { this.helper?.kill(); } catch { /* ignore */ } this.helper = null; this.teardownStreamer(this.streamer); this.streamer = null; this.live = false; this.starting = false; } }