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>
126 lines
4.2 KiB
TypeScript
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);
|