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:
70
bot/src/broadcast.test.ts
Normal file
70
bot/src/broadcast.test.ts
Normal 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");
|
||||||
|
});
|
||||||
89
bot/src/broadcast.ts
Normal file
89
bot/src/broadcast.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,23 +19,13 @@ import { AttachmentBuilder } from "discord.js";
|
|||||||
import { config } from "./config.ts";
|
import { config } from "./config.ts";
|
||||||
import { ask, health } from "./bridge.ts";
|
import { ask, health } from "./bridge.ts";
|
||||||
import { joinChannel, leaveGuild, getSession } from "./voice.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({
|
const client = new Client({
|
||||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
|
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;
|
const eph = { flags: MessageFlags.Ephemeral } as const;
|
||||||
|
|
||||||
client.once("clientReady", () => {
|
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
|
// each turn (search routes Chrome while live / Gemini when off), and let the
|
||||||
// brain toggle it via voice ("방송 켜줘 / 꺼줘"). In voice-only mode
|
// brain toggle it via voice ("방송 켜줘 / 꺼줘"). In voice-only mode
|
||||||
// (STREAM_BROWSER=false) none of this runs and the broadcast stays off.
|
// (STREAM_BROWSER=false) none of this runs and the broadcast stays off.
|
||||||
if (config.screenBrowser) {
|
await maybeCoupleBroadcast(session, { guildId: i.guildId!, voiceChannelId: channel.id });
|
||||||
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 i.editReply(`🎙️ '${channel.name}' 채널에 접속했습니다. 말씀하세요.`);
|
await i.editReply(`🎙️ '${channel.name}' 채널에 접속했습니다. 말씀하세요.`);
|
||||||
}
|
}
|
||||||
@@ -112,14 +86,7 @@ async function handleLeave(i: ChatInputCommandInteraction) {
|
|||||||
const left = leaveGuild(i.guildId!);
|
const left = leaveGuild(i.guildId!);
|
||||||
// Leaving voice also ends the broadcast — don't leave a stream live with no
|
// Leaving voice also ends the broadcast — don't leave a stream live with no
|
||||||
// session driving it.
|
// session driving it.
|
||||||
const streamer = streamers.get(i.guildId!);
|
await stopBroadcast(i.guildId!);
|
||||||
if (streamer?.isActive()) {
|
|
||||||
try {
|
|
||||||
await streamer.stop();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[leave] stopping broadcast failed:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await i.reply({ content: left ? "음성 채널에서 나갔습니다." : "접속 중인 세션이 없습니다.", ...eph });
|
await i.reply({ content: left ? "음성 채널에서 나갔습니다." : "접속 중인 세션이 없습니다.", ...eph });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +125,7 @@ async function handleStream(i: ChatInputCommandInteraction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleStop(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 });
|
if (!streamer) return i.reply({ content: "송출 중이 아닙니다.", ...eph });
|
||||||
await streamer.stop();
|
await streamer.stop();
|
||||||
await i.reply({ content: "송출을 중단했습니다.", ...eph });
|
await i.reply({ content: "송출을 중단했습니다.", ...eph });
|
||||||
@@ -174,7 +141,7 @@ async function handleStatus(i: ChatInputCommandInteraction) {
|
|||||||
/* keep unreachable */
|
/* keep unreachable */
|
||||||
}
|
}
|
||||||
const session = getSession(i.guildId!);
|
const session = getSession(i.guildId!);
|
||||||
const streamer = streamers.get(i.guildId!);
|
const streamer = peekStreamer(i.guildId!);
|
||||||
await i.editReply(
|
await i.editReply(
|
||||||
[
|
[
|
||||||
`브릿지 두뇌: ${brain}`,
|
`브릿지 두뇌: ${brain}`,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
import type { VoiceBasedChannel } from "discord.js";
|
import type { VoiceBasedChannel } from "discord.js";
|
||||||
import { config } from "./config.ts";
|
import { config } from "./config.ts";
|
||||||
import { joinChannel, leaveGuild } from "./voice.ts";
|
import { joinChannel, leaveGuild } from "./voice.ts";
|
||||||
|
import { maybeCoupleBroadcast, stopBroadcast } from "./broadcast.ts";
|
||||||
|
|
||||||
type AnyClient = any;
|
type AnyClient = any;
|
||||||
|
|
||||||
@@ -43,6 +44,11 @@ async function joinAndListen(client: AnyClient, channelId: string): Promise<void
|
|||||||
// joinVoiceChannel (it exposes id, guild.id and guild.voiceAdapterCreator).
|
// joinVoiceChannel (it exposes id, guild.id and guild.voiceAdapterCreator).
|
||||||
const session = await joinChannel(channel as unknown as VoiceBasedChannel);
|
const session = await joinChannel(channel as unknown as VoiceBasedChannel);
|
||||||
session.onTurn = ({ transcript, reply }) => console.log(`🗣️ ${transcript}\n🤖 ${reply}`);
|
session.onTurn = ({ transcript, reply }) => 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}' 음성채널에 참여했습니다.`);
|
console.log(`🎙️ 유저봇이 '${channel.name}' 음성채널에 참여했습니다.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +78,9 @@ export async function runUserbot(): Promise<void> {
|
|||||||
const ch = msg.member?.voice?.channel || msg.author?.voice?.channel;
|
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));
|
if (ch) await joinAndListen(client, ch.id).catch((e) => console.error("[userbot] join cmd:", e));
|
||||||
} else if (content === "!자비스 leave" || content === "!jarvis leave") {
|
} 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);
|
leaveGuild(config.guildId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user