From 568a1ae50b1b1bf5cc72c417e33ea77527b105b9 Mon Sep 17 00:00:00 2001 From: javis-bot Date: Sat, 13 Jun 2026 14:39:29 +0900 Subject: [PATCH] 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 --- bot/src/broadcast.test.ts | 70 ++++++++++++++++++++++++++++++ bot/src/broadcast.ts | 89 +++++++++++++++++++++++++++++++++++++++ bot/src/index.ts | 45 +++----------------- bot/src/userbot.ts | 9 ++++ 4 files changed, 174 insertions(+), 39 deletions(-) create mode 100644 bot/src/broadcast.test.ts create mode 100644 bot/src/broadcast.ts diff --git a/bot/src/broadcast.test.ts b/bot/src/broadcast.test.ts new file mode 100644 index 0000000..92e8c2b --- /dev/null +++ b/bot/src/broadcast.test.ts @@ -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"); +}); diff --git a/bot/src/broadcast.ts b/bot/src/broadcast.ts new file mode 100644 index 0000000..598ebc9 --- /dev/null +++ b/bot/src/broadcast.ts @@ -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(); + +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; + +/** + * 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 { + 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 { + 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 { + const s = streamers.get(guildId); + if (s?.isActive()) { + try { + await s.stop(); + } catch (e) { + console.error("[broadcast] stop failed:", e); + } + } +} diff --git a/bot/src/index.ts b/bot/src/index.ts index f72eefd..7013bee 100644 --- a/bot/src/index.ts +++ b/bot/src/index.ts @@ -19,23 +19,13 @@ import { AttachmentBuilder } from "discord.js"; import { config } from "./config.ts"; import { ask, health } from "./bridge.ts"; import { joinChannel, leaveGuild, getSession } from "./voice.ts"; -import { createStreamer, type ScreenStreamer, type StreamContext } from "./stream/index.ts"; +import { type StreamContext } from "./stream/index.ts"; +import { getStreamer, peekStreamer, maybeCoupleBroadcast, stopBroadcast } from "./broadcast.ts"; const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates], }); -const streamers = new Map(); - -async function getStreamer(guildId: string): Promise { - let s = streamers.get(guildId); - if (!s) { - s = await createStreamer(config); - streamers.set(guildId, s); - } - return s; -} - const eph = { flags: MessageFlags.Ephemeral } as const; client.once("clientReady", () => { @@ -87,23 +77,7 @@ async function handleJoin(i: ChatInputCommandInteraction) { // each turn (search routes Chrome while live / Gemini when off), and let the // brain toggle it via voice ("방송 켜줘 / 꺼줘"). In voice-only mode // (STREAM_BROWSER=false) none of this runs and the broadcast stays off. - if (config.screenBrowser) { - const streamer = await getStreamer(i.guildId!); - const ctx: StreamContext = { guildId: i.guildId!, voiceChannelId: channel.id }; - 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("[join] auto-broadcast failed:", e); - } - } + await maybeCoupleBroadcast(session, { guildId: i.guildId!, voiceChannelId: channel.id }); await i.editReply(`🎙️ '${channel.name}' 채널에 접속했습니다. 말씀하세요.`); } @@ -112,14 +86,7 @@ async function handleLeave(i: ChatInputCommandInteraction) { const left = leaveGuild(i.guildId!); // Leaving voice also ends the broadcast — don't leave a stream live with no // session driving it. - const streamer = streamers.get(i.guildId!); - if (streamer?.isActive()) { - try { - await streamer.stop(); - } catch (e) { - console.error("[leave] stopping broadcast failed:", e); - } - } + await stopBroadcast(i.guildId!); await i.reply({ content: left ? "음성 채널에서 나갔습니다." : "접속 중인 세션이 없습니다.", ...eph }); } @@ -158,7 +125,7 @@ async function handleStream(i: ChatInputCommandInteraction) { } async function handleStop(i: ChatInputCommandInteraction) { - const streamer = streamers.get(i.guildId!); + const streamer = peekStreamer(i.guildId!); if (!streamer) return i.reply({ content: "송출 중이 아닙니다.", ...eph }); await streamer.stop(); await i.reply({ content: "송출을 중단했습니다.", ...eph }); @@ -174,7 +141,7 @@ async function handleStatus(i: ChatInputCommandInteraction) { /* keep unreachable */ } const session = getSession(i.guildId!); - const streamer = streamers.get(i.guildId!); + const streamer = peekStreamer(i.guildId!); await i.editReply( [ `브릿지 두뇌: ${brain}`, diff --git a/bot/src/userbot.ts b/bot/src/userbot.ts index 28c2b46..4f8ef72 100644 --- a/bot/src/userbot.ts +++ b/bot/src/userbot.ts @@ -19,6 +19,7 @@ import type { VoiceBasedChannel } from "discord.js"; import { config } from "./config.ts"; import { joinChannel, leaveGuild } from "./voice.ts"; +import { maybeCoupleBroadcast, stopBroadcast } from "./broadcast.ts"; type AnyClient = any; @@ -43,6 +44,11 @@ async function joinAndListen(client: AnyClient, channelId: string): Promise console.log(`🗣️ ${transcript}\n🤖 ${reply}`); + // Screen-share mode (STREAM_BROWSER=true): auto-start the broadcast on join, + // report its live state to the brain each turn, and let the brain toggle it by + // voice. Userbot is the only mode that can actually Go Live, so without this + // wiring the broadcast never starts. No-op when STREAM_BROWSER=false. + await maybeCoupleBroadcast(session, { guildId: channel.guild.id, voiceChannelId: channel.id }); console.log(`🎙️ 유저봇이 '${channel.name}' 음성채널에 참여했습니다.`); } @@ -72,6 +78,9 @@ export async function runUserbot(): Promise { const ch = msg.member?.voice?.channel || msg.author?.voice?.channel; if (ch) await joinAndListen(client, ch.id).catch((e) => console.error("[userbot] join cmd:", e)); } else if (content === "!자비스 leave" || content === "!jarvis leave") { + // Leaving voice also ends the broadcast — don't leave a stream live with + // no session driving it. + await stopBroadcast(config.guildId); leaveGuild(config.guildId); } });