From 4176a68873f45b05b62b8154345a6a13611bb723 Mon Sep 17 00:00:00 2001 From: javis-bot Date: Wed, 10 Jun 2026 15:21:44 +0900 Subject: [PATCH] fix(selfbot): smooth VNC capture via keepalive + stop ffmpeg leak on stream end The Go-Live broadcast looked badly choppy: video and scrolling stuttered while the cursor stayed smooth. Root cause is TigerVNC: it only refreshes its framebuffer while a VNC client is attached, but the broadcast reads that framebuffer with x11grab (not as a VNC client). With no viewer attached the captured screen idled at ~1.5 fps (measured 3/30 distinct frames); the cursor looked smooth only because x11grab overlays the live cursor on every frame. - Add a headless RFB keepalive (vnc-keepalive.ts) that stays connected for the life of the stream and requests incremental framebuffer updates at the stream framerate. SelfbotStreamer starts it on broadcast start and tears it down on stop/self-end. Measured 3/30 -> 57/60 distinct frames at 60 fps. Fail-open; authenticates with VNC_PASSWORD or the ~/.config/tigervnc/passwd file. - Fix a resource leak: when the Go-Live ended on its own, only the active flag was cleared, leaving the x11grab->nvenc ffmpeg running forever (pinning a CPU core while no media was transmitted, with only the gateway TCP left and no UDP media). The self-end path now tears down capture, keepalive and voice like stop() does. - Tests for both paths (self-end teardown; keepalive DES auth, port mapping, password resolution). Add @types/bun so bun:test typechecks; document the keepalive and recommended Chrome flags in README and .env.example. Co-Authored-By: Claude Opus 4.7 --- .env.example | 6 + bot/bun.lock | 5 + bot/package.json | 1 + bot/scripts/stream-test/README.md | 19 +++ bot/src/stream/selfbot.test.ts | 61 ++++++++ bot/src/stream/selfbot.ts | 63 ++++++++- bot/src/stream/vnc-keepalive.test.ts | 53 +++++++ bot/src/stream/vnc-keepalive.ts | 203 +++++++++++++++++++++++++++ bot/tsconfig.json | 2 +- 9 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 bot/src/stream/selfbot.test.ts create mode 100644 bot/src/stream/vnc-keepalive.test.ts create mode 100644 bot/src/stream/vnc-keepalive.ts diff --git a/.env.example b/.env.example index 2c84a53..a4ca8de 100644 --- a/.env.example +++ b/.env.example @@ -44,7 +44,13 @@ WHISPER_MODEL=small # Docker desktop (VNC) β€” used only by the container image # --------------------------------------------------------------------------- # VNC viewer password (max 8 chars effective). Watch the screen at localhost:5901. +# Also used by the broadcast keepalive: TigerVNC only refreshes its framebuffer +# while a VNC client is attached, so the stream keeps a tiny client connected to +# avoid a choppy (~1.5 fps) capture. Must match the VNC server's password. If +# unset, the keepalive falls back to the obfuscated passwd file (VNC_PASSWD_FILE, +# default ~/.config/tigervnc/passwd). VNC_PASSWORD=javis123 +# VNC_PASSWD_FILE=/home/claude/.config/tigervnc/passwd # Auto-opened page in the in-container Chrome. CHROME_START_URL=about:blank diff --git a/bot/bun.lock b/bot/bun.lock index d61b60d..a7ef534 100644 --- a/bot/bun.lock +++ b/bot/bun.lock @@ -14,6 +14,7 @@ "qrcode": "^1.5.4", }, "devDependencies": { + "@types/bun": "^1.3.14", "@types/node": "^22.7.0", "@types/qrcode": "^1.5.6", "typescript": "^5.6.3", @@ -211,6 +212,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/node": ["@types/node@22.19.20", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw=="], "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], @@ -243,6 +246,8 @@ "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], diff --git a/bot/package.json b/bot/package.json index edfea44..91f22fe 100644 --- a/bot/package.json +++ b/bot/package.json @@ -24,6 +24,7 @@ "discord.js-selfbot-v13": "^3.7.1" }, "devDependencies": { + "@types/bun": "^1.3.14", "@types/node": "^22.7.0", "@types/qrcode": "^1.5.6", "typescript": "^5.6.3" diff --git a/bot/scripts/stream-test/README.md b/bot/scripts/stream-test/README.md index 92933fb..e41dbf5 100644 --- a/bot/scripts/stream-test/README.md +++ b/bot/scripts/stream-test/README.md @@ -32,6 +32,25 @@ bun bot/scripts/stream-test/stream-hold.ts node bot/scripts/stream-test/scenario.mjs ``` +Recommended Chrome flags on the streamed display (avoids the "restore pages?" +bubble after an unclean exit and keeps a single clean window): +``` +google-chrome --remote-debugging-port=9222 --start-maximized \ + --hide-crash-restore-bubble --disable-session-crashed-bubble \ + --autoplay-policy=no-user-gesture-required +``` + +## Smooth capture (VNC keepalive) +TigerVNC only refreshes its framebuffer while a VNC client is attached. The +Discord broadcast reads the framebuffer with `x11grab` (not as a VNC client), so +with no viewer attached the captured screen idles at ~1.5 fps and the stream +looks badly choppy while the cursor still moves smoothly (x11grab overlays the +live cursor each frame). `SelfbotStreamer` fixes this automatically: it keeps a +tiny headless RFB client (`vnc-keepalive.ts`) connected for the life of the +stream, requesting incremental updates at the stream framerate. Measured: 3/30 +distinct frames without it, ~57/60 with it. The keepalive authenticates with +`VNC_PASSWORD` (or the `~/.config/tigervnc/passwd` file) and is fail-open. + ## A/B framerate/resolution Lower settings to compare what Discord actually delivers to viewers, e.g.: ``` diff --git a/bot/src/stream/selfbot.test.ts b/bot/src/stream/selfbot.test.ts new file mode 100644 index 0000000..72db9f3 --- /dev/null +++ b/bot/src/stream/selfbot.test.ts @@ -0,0 +1,61 @@ +// Regression: when the Go-Live stream ends on its own (Discord closes it, the +// voice UDP drops, or ffmpeg exits) instead of via stop(), the capture ffmpeg +// MUST be killed and voice left. Otherwise the x11grab->nvenc encoder keeps +// running forever, feeding a pipe nobody reads, pinning a CPU core while no +// media is actually transmitted (observed live: 0 UDP sockets, 100% CPU). +import { test, expect, mock } from "bun:test"; + +test("a self-ended stream tears down the capture pipeline (no ffmpeg leak)", async () => { + const kill = mock(() => {}); + const leaveVoice = mock(() => {}); + const destroy = mock(() => {}); + + // Controllable Go-Live: resolve endStream() to simulate Discord closing it. + let endStream!: () => void; + const playPromise = new Promise((r) => { + endStream = r; + }); + + 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; + }, + prepareStream: () => ({ command: { on() {} }, output: {} }), + playStream: () => playPromise, + })); + + const { SelfbotStreamer } = await import("./selfbot.ts"); + const s = new SelfbotStreamer({ + selfbotToken: "token", + vncDisplay: ":1", + vncResolution: "1920x1080", + vncFramerate: 60, + vncBitrateKbps: 8000, + streamHw: true, + } as any); + + await s.start({ guildId: "g", voiceChannelId: "v" } as any); + expect(s.isActive()).toBe(true); + + // Discord closes the Go-Live on its own (not a stop() call). + endStream(); + await new Promise((r) => setTimeout(r, 0)); + + expect(kill).toHaveBeenCalled(); // capture ffmpeg killed -> no CPU-burning orphan + expect(leaveVoice).toHaveBeenCalled(); // voice connection released + expect(s.isActive()).toBe(false); +}, 30000); diff --git a/bot/src/stream/selfbot.ts b/bot/src/stream/selfbot.ts index c7b1813..f878172 100644 --- a/bot/src/stream/selfbot.ts +++ b/bot/src/stream/selfbot.ts @@ -19,11 +19,13 @@ 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; @@ -94,6 +96,7 @@ export class SelfbotStreamer implements ScreenStreamer { 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(); @@ -157,6 +160,23 @@ export class SelfbotStreamer implements ScreenStreamer { 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, { @@ -182,8 +202,35 @@ export class SelfbotStreamer implements ScreenStreamer { if (!signal.aborted) console.error("[selfbot] playStream:", err); }) .finally(() => { - // The stream ended on its own (not via stop()); release the lock. - if (this.controller === controller) this.active = false; + // 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)."; @@ -196,6 +243,11 @@ export class SelfbotStreamer implements ScreenStreamer { } catch { /* ignore */ } + try { + keepalive?.stop(); + } catch { + /* ignore */ + } try { streamer?.leaveVoice?.(); streamer?.client?.destroy?.(); @@ -208,6 +260,7 @@ export class SelfbotStreamer implements ScreenStreamer { // 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; @@ -226,6 +279,12 @@ export class SelfbotStreamer implements ScreenStreamer { /* ignore */ } this.capture = null; + try { + this.keepalive?.stop(); + } catch { + /* ignore */ + } + this.keepalive = null; try { this.streamer?.leaveVoice?.(); this.streamer?.client?.destroy?.(); diff --git a/bot/src/stream/vnc-keepalive.test.ts b/bot/src/stream/vnc-keepalive.test.ts new file mode 100644 index 0000000..94a44f5 --- /dev/null +++ b/bot/src/stream/vnc-keepalive.test.ts @@ -0,0 +1,53 @@ +import { test, expect } from "bun:test"; +import crypto from "node:crypto"; +import { + decodeVncPassword, + vncChallengeResponse, + vncPortForDisplay, + resolveVncPassword, +} from "./vnc-keepalive.ts"; + +// Independent reference for VNC's bit-reversed-key DES, to cross-check the module. +const rev = (b: number) => { + let r = 0; + for (let i = 0; i < 8; i++) r = (r << 1) | ((b >> i) & 1); + return r & 0xff; +}; +const vncKey = (buf: Buffer) => Buffer.from([...buf.subarray(0, 8)].map(rev)); +const desEnc = (key: Buffer, data: Buffer) => { + const c = crypto.createCipheriv("des-ecb", key, null); + c.setAutoPadding(false); + return Buffer.concat([c.update(data), c.final()]); +}; +const FIXED_KEY = Buffer.from([23, 82, 107, 6, 35, 78, 88, 7]); + +test("decodeVncPassword inverts the fixed-key obfuscation", () => { + const pw = Buffer.from("s3cr3t\0\0", "binary"); // 8 bytes, trailing nulls + const obf = desEnc(vncKey(FIXED_KEY), pw); // how vncpasswd stores it + expect(decodeVncPassword(obf).toString()).toBe("s3cr3t"); +}); + +test("vncChallengeResponse encrypts both challenge blocks with the bit-reversed password key", () => { + const pw = Buffer.from("hunter12"); + const challenge = crypto.randomBytes(16); + const expected = desEnc(vncKey(pw), challenge); + const got = vncChallengeResponse(pw, challenge); + expect(got.length).toBe(16); + expect(got.equals(expected)).toBe(true); +}); + +test("vncPortForDisplay maps an X display to its RFB port", () => { + expect(vncPortForDisplay(":1")).toBe(5901); + expect(vncPortForDisplay(":0")).toBe(5900); + expect(vncPortForDisplay(":5")).toBe(5905); +}); + +test("resolveVncPassword prefers the VNC_PASSWORD env var", () => { + const pw = resolveVncPassword({ VNC_PASSWORD: "letmein9" } as NodeJS.ProcessEnv); + expect(pw?.toString()).toBe("letmein9"); +}); + +test("resolveVncPassword returns null when nothing is available", () => { + const pw = resolveVncPassword({ VNC_PASSWD_FILE: "/nonexistent/path/xyz" } as NodeJS.ProcessEnv); + expect(pw).toBeNull(); +}); diff --git a/bot/src/stream/vnc-keepalive.ts b/bot/src/stream/vnc-keepalive.ts new file mode 100644 index 0000000..7e097f1 --- /dev/null +++ b/bot/src/stream/vnc-keepalive.ts @@ -0,0 +1,203 @@ +/** + * Headless RFB (VNC) keepalive client. + * + * TigerVNC's Xvnc only flushes pending rendering into the readable framebuffer + * while a VNC client is actively pulling updates. The Discord broadcast reads + * that framebuffer with x11grab (it is NOT a VNC client), so with no viewer + * attached Xvnc idles and the captured screen updates at ~1.5 fps - the stream + * looks badly choppy even though Chrome renders at 60 fps. (Measured: 3/30 + * distinct frames without a client, 30/30 with one.) + * + * This client stays connected to the VNC server and continuously requests + * incremental framebuffer updates, keeping the framebuffer fresh for the whole + * duration of a broadcast. It is intentionally fail-open: any connection/auth + * problem is logged and retried, never thrown, so it can never break the stream. + */ +import net from "node:net"; +import crypto from "node:crypto"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; + +// VNC's DES variant uses each key byte with its bits mirrored. +function revByte(b: number): number { + let r = 0; + for (let i = 0; i < 8; i++) r = (r << 1) | ((b >> i) & 1); + return r & 0xff; +} +function vncKey(buf: Buffer): Buffer { + return Buffer.from([...buf.subarray(0, 8)].map(revByte)); +} +function desEcb(key: Buffer, data: Buffer, decrypt = false): Buffer { + const c = decrypt + ? crypto.createDecipheriv("des-ecb", key, null) + : crypto.createCipheriv("des-ecb", key, null); + c.setAutoPadding(false); + return Buffer.concat([c.update(data), c.final()]); +} + +// The fixed key TigerVNC/RealVNC use to obfuscate the stored password file. +const FIXED_KEY = Buffer.from([23, 82, 107, 6, 35, 78, 88, 7]); + +/** Decode an 8-byte obfuscated VNC password file payload to plaintext. */ +export function decodeVncPassword(obf: Buffer): Buffer { + const pt = desEcb(vncKey(FIXED_KEY), obf.subarray(0, 8), true); + const z = pt.indexOf(0); + return pt.subarray(0, z < 0 ? 8 : z); +} + +/** Compute the 16-byte VncAuth response for a server challenge. */ +export function vncChallengeResponse(password: Buffer, challenge: Buffer): Buffer { + const key = Buffer.alloc(8); + password.subarray(0, 8).copy(key); + return desEcb(vncKey(key), challenge.subarray(0, 16)); +} + +/** Map an X display like ":1" to its TigerVNC RFB port (5900 + n). */ +export function vncPortForDisplay(display: string): number { + const n = parseInt(String(display).replace(/^:/, ""), 10); + return 5900 + (Number.isFinite(n) ? n : 0); +} + +/** + * Resolve the VNC password: VNC_PASSWORD (plaintext) wins, otherwise decode the + * obfuscated passwd file (VNC_PASSWD_FILE, default ~/.config/tigervnc/passwd). + * Returns null when nothing is available (caller then skips the keepalive). + */ +export function resolveVncPassword(env: NodeJS.ProcessEnv = process.env): Buffer | null { + if (env.VNC_PASSWORD) return Buffer.from(env.VNC_PASSWORD, "utf8").subarray(0, 8); + const file = env.VNC_PASSWD_FILE || `${homedir()}/.config/tigervnc/passwd`; + try { + const obf = readFileSync(file); + if (obf.length >= 8) return decodeVncPassword(obf); + } catch { + /* no file - fall through */ + } + return null; +} + +export class VncKeepalive { + private sock: net.Socket | null = null; + private timer: ReturnType | null = null; + private retry: ReturnType | null = null; + private stopped = false; + + constructor( + private opts: { host: string; port: number; password: Buffer; fps?: number }, + ) {} + + start(): void { + this.stopped = false; + this.connect(); + } + + stop(): void { + this.stopped = true; + if (this.timer) clearInterval(this.timer); + if (this.retry) clearTimeout(this.retry); + this.timer = this.retry = null; + try { + this.sock?.destroy(); + } catch { + /* ignore */ + } + this.sock = null; + } + + private scheduleReconnect(): void { + if (this.stopped || this.retry) return; + this.retry = setTimeout(() => { + this.retry = null; + if (!this.stopped) this.connect(); + }, 2000); + } + + private connect(): void { + const sock = net.connect(this.opts.port, this.opts.host); + this.sock = sock; + sock.setNoDelay(true); + + let buf = Buffer.alloc(0); + const waiters: { n: number; res: (b: Buffer) => void }[] = []; + const pump = () => { + while (waiters.length && buf.length >= waiters[0].n) { + const w = waiters.shift()!; + const d = buf.subarray(0, w.n); + buf = buf.subarray(w.n); + w.res(d); + } + }; + const onData = (d: Buffer) => { + buf = Buffer.concat([buf, d]); + pump(); + }; + sock.on("data", onData); + sock.on("error", () => { + /* handled by close */ + }); + sock.on("close", () => { + if (this.sock === sock) { + this.sock = null; + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + this.scheduleReconnect(); + } + }); + const read = (n: number) => + new Promise((res) => { + waiters.push({ n, res }); + pump(); + }); + + (async () => { + try { + await read(12); // ProtocolVersion + sock.write("RFB 003.008\n"); + const nTypes = (await read(1))[0]; + const types = await read(nTypes); + if (types.includes(2)) { + sock.write(Buffer.from([2])); // VNC Auth + const challenge = await read(16); + sock.write(vncChallengeResponse(this.opts.password, challenge)); + if ((await read(4)).readUInt32BE(0) !== 0) return sock.destroy(); + } else if (types.includes(1)) { + sock.write(Buffer.from([1])); // None + if ((await read(4)).readUInt32BE(0) !== 0) return sock.destroy(); + } else { + return sock.destroy(); + } + sock.write(Buffer.from([1])); // ClientInit (shared) + const si = await read(24); + const w = si.readUInt16BE(0); + const h = si.readUInt16BE(2); + await read(si.readUInt32BE(20)); // desktop name + sock.write(Buffer.from([2, 0, 0, 1, 0, 0, 0, 0])); // SetEncodings: Raw + + // Past the handshake: stop buffering and just drain whatever the server + // sends so its send buffer never blocks (we never decode the pixels). + sock.removeListener("data", onData); + sock.on("data", () => {}); + buf = Buffer.alloc(0); + + const req = Buffer.from([3, 1, 0, 0, 0, 0, 0, 0, 0, 0]); + req.writeUInt16BE(w, 6); + req.writeUInt16BE(h, 8); + const interval = Math.max(1, Math.round(1000 / (this.opts.fps ?? 60))); + this.timer = setInterval(() => { + try { + if (this.sock === sock && sock.writable) sock.write(req); + } catch { + /* ignore */ + } + }, interval); + } catch { + try { + sock.destroy(); + } catch { + /* ignore */ + } + } + })(); + } +} diff --git a/bot/tsconfig.json b/bot/tsconfig.json index 11e60c8..12b4ba1 100644 --- a/bot/tsconfig.json +++ b/bot/tsconfig.json @@ -4,7 +4,7 @@ "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2022"], - "types": ["node"], + "types": ["node", "bun"], "strict": true, "noEmit": true, "esModuleInterop": true,