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

View File

@@ -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<string, ScreenStreamer>();
async function getStreamer(guildId: string): Promise<ScreenStreamer> {
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}`,