fix(bot): auto-start broadcast on voice join in userbot mode

The "couple broadcast to voice" feature only wired auto-start into the
normal-bot join handler. Userbot mode (the only mode that can actually Go
Live) was added later with Go-Live deferred to a "stage 2" that never
landed, so the running deployment had no path to start a broadcast.

Extract the coupling into a shared bot/src/broadcast.ts (auto-start on
join, report live state to the brain each turn, voice toggle) and wire it
into both index.ts and userbot.ts so both modes behave identically. Add a
behavioural test for the auto-start + toggle contract.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
javis-bot
2026-06-13 14:39:29 +09:00
parent d6c029d7d5
commit 568a1ae50b
4 changed files with 174 additions and 39 deletions

70
bot/src/broadcast.test.ts Normal file
View File

@@ -0,0 +1,70 @@
import { test, expect } from "bun:test";
// broadcast.ts pulls in the runtime `config`, which requires DISCORD_GUILD_ID.
// Set it before the dynamic import so the module loads without a real .env.
process.env.DISCORD_GUILD_ID ||= "test-guild";
const { coupleBroadcast } = await import("./broadcast.ts");
/** Minimal in-memory ScreenStreamer that records start/stop calls. */
function fakeStreamer(active = false) {
const calls: string[] = [];
let on = active;
return {
calls,
kind: "selfbot" as const,
isActive: () => on,
async start() {
calls.push("start");
on = true;
return "ok";
},
async stop() {
calls.push("stop");
on = false;
},
};
}
const ctx = { guildId: "g", voiceChannelId: "c" };
test("auto-starts the broadcast when joining with the stream idle", async () => {
const s = fakeStreamer(false);
const session: any = {};
await coupleBroadcast(session, s as any, ctx);
expect(s.calls).toEqual(["start"]);
expect(session.getBroadcasting()).toBe(true);
});
test("does not restart a broadcast that is already live", async () => {
const s = fakeStreamer(true);
const session: any = {};
await coupleBroadcast(session, s as any, ctx);
expect(s.calls).toEqual([]);
expect(session.getBroadcasting()).toBe(true);
});
test("the brain can toggle the stream by voice via onBroadcastAction", async () => {
const s = fakeStreamer(false);
const session: any = {};
await coupleBroadcast(session, s as any, ctx); // auto-start fires
await session.onBroadcastAction("start"); // already live -> no-op
await session.onBroadcastAction("stop"); // stops
expect(session.getBroadcasting()).toBe(false);
await session.onBroadcastAction("start"); // restarts
expect(session.getBroadcasting()).toBe(true);
expect(s.calls).toEqual(["start", "stop", "start"]);
});
test("an auto-start failure is swallowed but the toggle hooks are still wired", async () => {
const s: any = {
isActive: () => false,
async start() {
throw new Error("boom");
},
async stop() {},
};
const session: any = {};
await coupleBroadcast(session, s, ctx);
expect(typeof session.getBroadcasting).toBe("function");
expect(typeof session.onBroadcastAction).toBe("function");
});