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

89
bot/src/broadcast.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* Broadcast <-> voice coupling, shared by the normal-bot and userbot entry
* points so both modes behave identically.
*
* The screen broadcast is coupled to the voice session: it auto-starts when the
* assistant joins a voice channel (screen-share mode default), reports its live
* state to the brain each turn (search routes Chrome while live / Gemini when
* off), and the brain can toggle it by voice ("방송 켜줘 / 꺼줘"). This logic
* used to live only in the normal-bot join handler, so the userbot path (the
* only mode that can actually Go Live) never started a broadcast. Keeping it
* here means a single implementation serves both.
*/
import { config } from "./config.ts";
import { createStreamer, type ScreenStreamer, type StreamContext } from "./stream/index.ts";
import type { VoiceSession } from "./voice.ts";
/** One streamer per guild, shared across both entry points. */
const streamers = new Map<string, ScreenStreamer>();
export async function getStreamer(guildId: string): Promise<ScreenStreamer> {
let s = streamers.get(guildId);
if (!s) {
s = await createStreamer(config);
streamers.set(guildId, s);
}
return s;
}
/** The existing streamer for a guild, if one has been created. */
export function peekStreamer(guildId: string): ScreenStreamer | undefined {
return streamers.get(guildId);
}
type BroadcastSession = Pick<VoiceSession, "getBroadcasting" | "onBroadcastAction">;
/**
* Wire a voice session to a streamer and auto-start the broadcast. Pure (no
* config / registry access) so it can be unit-tested with a fake streamer:
* - report live state to the brain each turn,
* - let the brain toggle the stream by voice,
* - auto-start on join (skipping if already live).
*
* Auto-start failures are swallowed so a broadcast problem never blocks the
* voice conversation from coming up.
*/
export async function coupleBroadcast(
session: BroadcastSession,
streamer: ScreenStreamer,
ctx: StreamContext,
): Promise<void> {
session.getBroadcasting = () => streamer.isActive();
session.onBroadcastAction = async (action) => {
if (action === "start") {
if (!streamer.isActive()) await streamer.start(ctx);
} else if (streamer.isActive()) {
await streamer.stop();
}
};
try {
if (!streamer.isActive()) await streamer.start(ctx);
} catch (e) {
console.error("[broadcast] auto-start failed:", e);
}
}
/**
* Couple the broadcast to a freshly joined voice session when screen-share mode
* is on (STREAM_BROWSER=true). No-op in voice-only mode (STREAM_BROWSER=false).
*/
export async function maybeCoupleBroadcast(
session: BroadcastSession,
ctx: StreamContext,
): Promise<void> {
if (!config.screenBrowser) return;
const streamer = await getStreamer(ctx.guildId);
await coupleBroadcast(session, streamer, ctx);
}
/** Stop the broadcast for a guild if one is live (e.g. on leave). */
export async function stopBroadcast(guildId: string): Promise<void> {
const s = streamers.get(guildId);
if (s?.isActive()) {
try {
await s.stop();
} catch (e) {
console.error("[broadcast] stop failed:", e);
}
}
}