Files
javis_bot/bot/src/stream/selfbot.test.ts
javis-bot 2e21185dc0 fix(bot): never broadcast on the conversation's account (it deafened STT)
Critical regression: in single-account userbot mode the broadcast auto-start
logged in a SECOND session of the conversation account and called joinVoice,
which the streaming lib always sends with self_deaf:true. Voice state is
per-account, so this deafened the CONVERSATION session too and the bot silently
stopped hearing the user (STT receive broke). The Go-Live cannot connect on a
shared account anyway.

start() now refuses early (no joinVoice, no deafen) when there is no dedicated
DISCORD_STREAM_TOKEN and no normal-bot token, protecting the conversation. A
dedicated stream account re-enables the broadcast.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-13 21:33:50 +09:00

126 lines
4.2 KiB
TypeScript

// 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<void>((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;
// 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,
}));
const { SelfbotStreamer } = await import("./selfbot.ts");
const s = new SelfbotStreamer({
selfbotToken: "token",
streamToken: "stream-token", // dedicated broadcast account -> broadcast allowed
botToken: "",
vncDisplay: ":1",
vncResolution: "1920x1080",
vncFramerate: 60,
vncBitrateKbps: 8000,
streamHw: true,
streamReadyTimeoutMs: 2000,
} 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);
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<void>(() => {});
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",
streamToken: "stream-token", // dedicated broadcast account -> broadcast allowed
botToken: "",
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);