/** * 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"; export class SelfbotStreamer implements ScreenStreamer { readonly kind = "selfbot" as const; private streamer: any = null; private capture: ChildProcess | null = null; private controller: AbortController | null = null; private active = false; constructor(private config: AppConfig) {} isActive() { return this.active; } 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 "셀프봇 송출은 음성 채널 안에서 호출해야 합니다."; } const { selfbot, vs } = await this.loadLib(); const { Streamer, prepareStream, playStream } = vs; this.streamer = new Streamer(new selfbot.Client()); await this.streamer.client.login(this.config.selfbotToken); await this.streamer.joinVoice(ctx.guildId, ctx.voiceChannelId); const [w, h] = this.config.vncResolution.split("x").map((n) => parseInt(n, 10)); this.controller = new AbortController(); // 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; const maxKbps = Math.round(kbps * 1.5); const captureCodecArgs = hw ? ["-c:v", "h264_nvenc", "-preset", "p4", "-tune", "ll", "-forced-idr", "1"] : ["-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency"]; const capture = spawn("ffmpeg", [ "-loglevel", "error", "-f", "x11grab", "-framerate", String(this.config.vncFramerate), "-video_size", this.config.vncResolution, "-i", this.config.vncDisplay, ...captureCodecArgs, "-b:v", `${kbps}k`, "-maxrate", `${maxKbps}k`, "-bufsize", `${kbps}k`, "-bf", "0", "-pix_fmt", "yuv420p", "-g", String(this.config.vncFramerate), "-f", "mpegts", "pipe:1", ]); this.capture = capture; capture.stderr?.on("data", (d) => { if (!this.controller?.signal.aborted) console.error("[selfbot x11grab]", d.toString().trim()); }); 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, }, this.controller.signal, ); command.on("error", (err: Error) => { if (!this.controller?.signal.aborted) console.error("[selfbot] ffmpeg error:", err); }); this.active = true; playStream(output, this.streamer, { type: "go-live" }, this.controller.signal) .catch((err: Error) => console.error("[selfbot] playStream:", err)) .finally(() => { this.active = false; }); return "🔴 셀프봇으로 VNC 화면을 음성채널에 실시간 송출 중입니다 (Go Live)."; } async stop(): Promise { this.controller?.abort(); this.controller = null; try { this.capture?.kill("SIGKILL"); } catch { /* ignore */ } this.capture = null; try { this.streamer?.leaveVoice?.(); this.streamer?.client?.destroy?.(); } catch { /* ignore */ } this.streamer = null; this.active = false; } }