/** * 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(); export async function getStreamer(guildId: string): Promise { 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" | "getSharedSession" >; /** * Wire a voice session to a streamer (no auto-start). 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. */ export function wireBroadcast( session: BroadcastSession, streamer: ScreenStreamer, ctx: StreamContext, ): 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(); } }; } /** * Auto-start the broadcast, retrying a couple of times before giving up. The * selfbot Go-Live path takes ~10-15s of humanised delays and can fail on a * transient (login race, voice not ready yet), so a bounded retry recovers the * common case. On final failure it logs loudly rather than silently leaving the * user in voice with no broadcast. Never throws: a broadcast problem must not * tear down the voice conversation. Returns whether the stream ended up live. */ export async function autoStartBroadcast( streamer: ScreenStreamer, ctx: StreamContext, attempts = 2, retryDelayMs = 1500, ): Promise { for (let attempt = 1; attempt <= attempts; attempt++) { try { if (streamer.isActive()) return true; await streamer.start(ctx); if (streamer.isActive()) { console.log("🔴 [broadcast] auto-started on voice join (Go Live)"); return true; } console.error( `[broadcast] auto-start attempt ${attempt}/${attempts} did not go live`, ); } catch (e) { console.error(`[broadcast] auto-start attempt ${attempt}/${attempts} failed:`, e); } if (attempt < attempts && retryDelayMs > 0) { await new Promise((r) => setTimeout(r, retryDelayMs)); } } console.error( "[broadcast] ⚠️ auto-start FAILED after retries — voice is up but NOT broadcasting " + "(STREAM_BROWSER=true). Trigger it by voice ('방송 켜줘') or check the selfbot stream deps.", ); return false; } /** * 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). * * The toggle hooks are wired synchronously; the auto-start runs in the * BACKGROUND so the ~10-15s Go-Live handshake never blocks the voice session * from coming up. The bot can already hear and reply while the stream warms. */ export async function maybeCoupleBroadcast( session: BroadcastSession, ctx: StreamContext, ): Promise { if (!config.screenBrowser) return; const streamer = await getStreamer(ctx.guildId); // Let the selfbot backend broadcast on the conversation's own session // (single-account Go-Live) instead of a second login. const ctxWithSession: StreamContext = { ...ctx, getSharedSession: () => session.getSharedSession?.() ?? null, }; wireBroadcast(session, streamer, ctxWithSession); void autoStartBroadcast(streamer, ctxWithSession); } /** Stop the broadcast for a guild if one is live (e.g. on leave). */ export async function stopBroadcast(guildId: string): Promise { const s = streamers.get(guildId); if (s?.isActive()) { try { await s.stop(); } catch (e) { console.error("[broadcast] stop failed:", e); } } }