/** * 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 controller: AbortController | null = null; private active = false; constructor(private config: AppConfig) {} isActive() { return this.active; } /** * 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.active) return "이미 송출 중입니다."; if (!this.config.selfbotToken) { return "DISCORD_SELFBOT_TOKEN이 설정되지 않았습니다 (.env). 버너 계정 토큰을 넣어주세요."; } 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 /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; const controller = (this.controller = new AbortController()); const signal = controller.signal; let streamer: any = null; let capture: ChildProcess | null = null; let keepalive: VncKeepalive | null = null; try { const { selfbot, vs } = await this.loadLib(); const { Streamer, prepareStream, playStream } = vs; signal.throwIfAborted(); streamer = this.streamer = new Streamer(new selfbot.Client()); await streamer.client.login(this.config.selfbotToken); signal.throwIfAborted(); // Act like a person, not a bot: take a breath after coming online before // navigating into the voice channel, then settle in for a few seconds // before hitting "Go Live". Randomised so the cadence isn't // fingerprintable. throwIfAborted() after each pause unwinds into the // catch below if stop() lands mid-wait, so we never join/go-live on a // torn-down streamer. 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(); } 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 { streamer?.leaveVoice?.(); streamer?.client?.destroy?.(); } catch { /* ignore */ } if (this.capture === capture) this.capture = null; if (this.keepalive === keepalive) this.keepalive = null; if (this.streamer === streamer) this.streamer = null; this.controller = null; this.active = false; }); 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 { streamer?.leaveVoice?.(); streamer?.client?.destroy?.(); } catch { /* ignore */ } // 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 // 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.streamer === streamer) this.streamer = null; this.controller = null; this.active = 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.streamer?.leaveVoice?.(); this.streamer?.client?.destroy?.(); } catch { /* ignore */ } this.streamer = null; this.active = false; } }