Proven approach: the conversation (hear+speak) runs on @discordjs/voice; the
Go-Live broadcast is a SEPARATE stream connection created on the SAME selfbot
session (exactly like a real Discord client), so ONE account hears, speaks, AND
broadcasts — no second login, no self_deaf, no voice conflict.
- voice.ts captures its own voice session_id (adapter wrap) and exposes
getSharedSession() {client, guildId, channelId, sessionId, botId}.
- broadcast.ts threads it into the StreamContext.
- selfbot.ts: when a shared session is present, build the Streamer on the
conversation client and create the stream on its session_id (no login/joinVoice/
humanPause); teardown only stops the stream (never leaves voice/destroys the
shared client). Falls back to the dedicated-account path otherwise.
Verified live: Go-Live connected in ~7s while the conversation voice stayed
ready, and the broadcast was visible in Discord — all on one account.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
134 lines
4.8 KiB
TypeScript
134 lines
4.8 KiB
TypeScript
/**
|
|
* 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" | "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<boolean> {
|
|
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<void> {
|
|
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<void> {
|
|
const s = streamers.get(guildId);
|
|
if (s?.isActive()) {
|
|
try {
|
|
await s.stop();
|
|
} catch (e) {
|
|
console.error("[broadcast] stop failed:", e);
|
|
}
|
|
}
|
|
}
|