feat(stream): single-account Go-Live — broadcast on the conversation's session

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>
This commit is contained in:
javis-bot
2026-06-13 22:37:26 +09:00
parent e8234b7fb1
commit ddebdd7542
4 changed files with 126 additions and 53 deletions

View File

@@ -31,7 +31,10 @@ export function peekStreamer(guildId: string): ScreenStreamer | undefined {
return streamers.get(guildId); return streamers.get(guildId);
} }
type BroadcastSession = Pick<VoiceSession, "getBroadcasting" | "onBroadcastAction">; type BroadcastSession = Pick<
VoiceSession,
"getBroadcasting" | "onBroadcastAction" | "getSharedSession"
>;
/** /**
* Wire a voice session to a streamer (no auto-start). Pure (no config / registry * Wire a voice session to a streamer (no auto-start). Pure (no config / registry
@@ -107,8 +110,14 @@ export async function maybeCoupleBroadcast(
): Promise<void> { ): Promise<void> {
if (!config.screenBrowser) return; if (!config.screenBrowser) return;
const streamer = await getStreamer(ctx.guildId); const streamer = await getStreamer(ctx.guildId);
wireBroadcast(session, streamer, ctx); // Let the selfbot backend broadcast on the conversation's own session
void autoStartBroadcast(streamer, ctx); // (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). */ /** Stop the broadcast for a guild if one is live (e.g. on leave). */

View File

@@ -8,11 +8,26 @@
*/ */
import type { AppConfig } from "../config.ts"; import type { AppConfig } from "../config.ts";
/** A live voice session to reuse for single-account Go-Live: the conversation's
* own selfbot client + its negotiated voice session_id. The Go-Live stream is
* created on THIS session (no second login / voice join), so one account both
* hears/speaks (via @discordjs/voice) and broadcasts (via @dank074). */
export interface SharedVoiceSession {
client: unknown;
guildId: string;
channelId: string;
sessionId: string;
botId: string;
}
export interface StreamContext { export interface StreamContext {
guildId: string; guildId: string;
voiceChannelId: string; voiceChannelId: string;
/** Post an image to the invoking text channel (used by the screenshot backend). */ /** Post an image to the invoking text channel (used by the screenshot backend). */
postImage?: (png: Buffer, name: string) => Promise<void>; postImage?: (png: Buffer, name: string) => Promise<void>;
/** If present and it returns a session, the selfbot backend broadcasts on the
* conversation's existing session instead of a second login (single account). */
getSharedSession?: () => SharedVoiceSession | null;
} }
export interface ScreenStreamer { export interface ScreenStreamer {

View File

@@ -35,6 +35,26 @@ export class SelfbotStreamer implements ScreenStreamer {
// local "we kicked off ffmpeg" guess. // local "we kicked off ffmpeg" guess.
private starting = false; private starting = false;
private live = false; private live = false;
// Whether WE own the streamer's client (dedicated/separate account). When we
// ride the conversation's shared session we must NOT destroy its client or
// leave its voice on teardown — only stop the Go-Live stream.
private ownsClient = false;
/** Tear down the streamer's Discord side: full leave+destroy when we own the
* client, otherwise just stop the Go-Live stream (the conversation owns the
* client/voice). */
private teardownStreamer(streamer: any): void {
try {
if (this.ownsClient) {
streamer?.leaveVoice?.();
streamer?.client?.destroy?.();
} else {
streamer?.stopStream?.();
}
} catch {
/* ignore */
}
}
constructor(private config: AppConfig) {} constructor(private config: AppConfig) {}
@@ -141,20 +161,18 @@ export class SelfbotStreamer implements ScreenStreamer {
} }
const dedicatedStreamAccount = const dedicatedStreamAccount =
!!this.config.streamToken && this.config.streamToken !== this.config.selfbotToken; !!this.config.streamToken && this.config.streamToken !== this.config.selfbotToken;
// CRITICAL: in single-account userbot mode (no normal-bot token, no dedicated // Single-account userbot: broadcast on the conversation's OWN session — the
// stream token) we must NOT start the broadcast. The broadcaster logs in as a // Go-Live stream is a SEPARATE stream connection on the same session (exactly
// SECOND session of the conversation account and joins voice with // like a real Discord client), so it never deafens or fights the conversation
// self_deaf:true (the streaming lib always deafens itself). Voice state is // voice. Only when there is neither a shared session nor a dedicated account
// per-account, so that deafens the CONVERSATION session too and the bot stops // is the broadcast impossible: a second login would deafen the conversation,
// hearing the user (STT breaks). The Go-Live can't connect on a shared account // so refuse to protect it.
// anyway, so refuse early — protecting the conversation is more important than const shared = ctx.getSharedSession?.() ?? null;
// a broadcast that cannot work. A dedicated DISCORD_STREAM_TOKEN removes this. if (!shared && !dedicatedStreamAccount && !this.config.botToken) {
if (!dedicatedStreamAccount && !this.config.botToken) {
console.warn( console.warn(
"[selfbot] ⚠️ 방송 전용 DISCORD_STREAM_TOKEN 미설정 — 단일 계정 유저봇에서는 방송이 대화 음성을 " + "[selfbot] ⚠️ 공유 세션도 방송 전용 토큰도 없어 방송을 시작하지 않습니다 (대화 음성 보호).",
"끊으므로(self_deaf) 시작하지 않습니다. 별도 버너 계정 토큰(DISCORD_STREAM_TOKEN)을 설정하세요.",
); );
return "방송 전용 계정(DISCORD_STREAM_TOKEN)이 없어 대화 음성 보호를 위해 방송을 시작하지 않았습니다."; return "방송을 시작할 수 없습니다 (대화 세션 공유 불가 + 전용 계정 없음).";
} }
if (!ctx.voiceChannelId) { if (!ctx.voiceChannelId) {
return "셀프봇 송출은 음성 채널 안에서 호출해야 합니다."; return "셀프봇 송출은 음성 채널 안에서 호출해야 합니다.";
@@ -180,21 +198,36 @@ export class SelfbotStreamer implements ScreenStreamer {
const { Streamer, prepareStream, playStream } = vs; const { Streamer, prepareStream, playStream } = vs;
signal.throwIfAborted(); signal.throwIfAborted();
streamer = this.streamer = new Streamer(new selfbot.Client()); if (shared) {
await streamer.client.login(streamToken); // Ride the conversation's existing session: create the Go-Live STREAM
signal.throwIfAborted(); // connection on it (playStream below calls createStream against this
// voiceConnection) — no second login / voice join, which would deafen or
// Act like a person, not a bot: take a breath after coming online before // fight the conversation. type/botId/session_id are required by the
// navigating into the voice channel, then settle in for a few seconds // stream-create signalling.
// before hitting "Go Live". Randomised so the cadence isn't this.ownsClient = false;
// fingerprintable. throwIfAborted() after each pause unwinds into the streamer = this.streamer = new Streamer(shared.client);
// catch below if stop() lands mid-wait, so we never join/go-live on a (streamer as any)._voiceConnection = {
// torn-down streamer. guildId: shared.guildId,
await this.humanPause(2500, 4500, signal); channelId: shared.channelId,
signal.throwIfAborted(); session_id: shared.sessionId,
await streamer.joinVoice(ctx.guildId, ctx.voiceChannelId); type: "guild",
await this.humanPause(6000, 10000, signal); botId: shared.botId,
signal.throwIfAborted(); };
} else {
// Dedicated/separate account: log in and join voice ourselves. Act like
// a person, not a bot: breathe after coming online before joining, then
// settle before going Live. Randomised; throwIfAborted() after each pause
// unwinds into the catch if stop() lands mid-wait.
this.ownsClient = true;
streamer = this.streamer = new Streamer(new selfbot.Client());
await streamer.client.login(streamToken);
signal.throwIfAborted();
await this.humanPause(2500, 4500, signal);
signal.throwIfAborted();
await streamer.joinVoice(ctx.guildId, ctx.voiceChannelId);
await this.humanPause(6000, 10000, signal);
signal.throwIfAborted();
}
const [w, h] = this.config.vncResolution.split("x").map((n) => parseInt(n, 10)); const [w, h] = this.config.vncResolution.split("x").map((n) => parseInt(n, 10));
@@ -333,12 +366,7 @@ export class SelfbotStreamer implements ScreenStreamer {
} catch { } catch {
/* ignore */ /* ignore */
} }
try { this.teardownStreamer(streamer);
streamer?.leaveVoice?.();
streamer?.client?.destroy?.();
} catch {
/* ignore */
}
if (this.capture === capture) this.capture = null; if (this.capture === capture) this.capture = null;
if (this.keepalive === keepalive) this.keepalive = null; if (this.keepalive === keepalive) this.keepalive = null;
if (this.helper === helper) this.helper = null; if (this.helper === helper) this.helper = null;
@@ -367,10 +395,7 @@ export class SelfbotStreamer implements ScreenStreamer {
try { capture?.kill("SIGKILL"); } catch { /* ignore */ } try { capture?.kill("SIGKILL"); } catch { /* ignore */ }
try { keepalive?.stop(); } catch { /* ignore */ } try { keepalive?.stop(); } catch { /* ignore */ }
try { helper?.kill(); } catch { /* ignore */ } try { helper?.kill(); } catch { /* ignore */ }
try { this.teardownStreamer(streamer);
streamer?.leaveVoice?.();
streamer?.client?.destroy?.();
} catch { /* ignore */ }
if (this.controller === controller) { if (this.controller === controller) {
if (this.capture === capture) this.capture = null; if (this.capture === capture) this.capture = null;
if (this.keepalive === keepalive) this.keepalive = null; if (this.keepalive === keepalive) this.keepalive = null;
@@ -405,12 +430,7 @@ export class SelfbotStreamer implements ScreenStreamer {
} catch { } catch {
/* ignore */ /* ignore */
} }
try { this.teardownStreamer(streamer);
streamer?.leaveVoice?.();
streamer?.client?.destroy?.();
} catch {
/* ignore */
}
// Only release the lock / clear instance state if WE are still the // Only release the lock / clear instance state if WE are still the
// current attempt. If a concurrent stop()+start() already replaced the // current attempt. If a concurrent stop()+start() already replaced the
// controller, a newer start() owns the lock — clearing it here would // controller, a newer start() owns the lock — clearing it here would
@@ -450,12 +470,7 @@ export class SelfbotStreamer implements ScreenStreamer {
/* ignore */ /* ignore */
} }
this.helper = null; this.helper = null;
try { this.teardownStreamer(this.streamer);
this.streamer?.leaveVoice?.();
this.streamer?.client?.destroy?.();
} catch {
/* ignore */
}
this.streamer = null; this.streamer = null;
this.live = false; this.live = false;
this.starting = false; this.starting = false;

View File

@@ -86,12 +86,32 @@ export class VoiceSession {
/** Apply a broadcast directive the brain requested (start/stop the stream). */ /** Apply a broadcast directive the brain requested (start/stop the stream). */
onBroadcastAction?: (action: "start" | "stop") => void | Promise<void>; onBroadcastAction?: (action: "start" | "stop") => void | Promise<void>;
/** The selfbot client behind this voice connection + the negotiated voice
* session_id, so the broadcast (Go-Live) can ride the SAME session — one
* account hears/speaks AND broadcasts. */
private readonly client: any;
private readonly channelId: string;
private sessionId?: string;
constructor(channel: VoiceBasedChannel) { constructor(channel: VoiceBasedChannel) {
this.guildId = channel.guild.id; this.guildId = channel.guild.id;
this.channelId = channel.id;
this.client = (channel as any).client;
// Wrap the gateway adapter to capture our own voice session_id (needed to
// create the Go-Live stream on this same session).
const realCreator = channel.guild.voiceAdapterCreator;
const adapterCreator: typeof realCreator = (methods) =>
realCreator({
...methods,
onVoiceStateUpdate: (d: any) => {
if (d?.session_id) this.sessionId = d.session_id;
return methods.onVoiceStateUpdate(d);
},
});
this.connection = joinVoiceChannel({ this.connection = joinVoiceChannel({
channelId: channel.id, channelId: channel.id,
guildId: channel.guild.id, guildId: channel.guild.id,
adapterCreator: channel.guild.voiceAdapterCreator, adapterCreator,
selfDeaf: false, // we need to hear users selfDeaf: false, // we need to hear users
selfMute: false, selfMute: false,
}); });
@@ -102,6 +122,20 @@ export class VoiceSession {
this.attachReceiver(); this.attachReceiver();
} }
/** The shared session for single-account Go-Live, or null if session_id isn't
* captured yet / the client is unavailable. */
getSharedSession(): {
client: unknown;
guildId: string;
channelId: string;
sessionId: string;
botId: string;
} | null {
const botId = this.client?.user?.id;
if (!this.sessionId || !this.client || !botId) return null;
return { client: this.client, guildId: this.guildId, channelId: this.channelId, sessionId: this.sessionId, botId };
}
async ready(): Promise<void> { async ready(): Promise<void> {
await entersState(this.connection, VoiceConnectionStatus.Ready, 20_000); await entersState(this.connection, VoiceConnectionStatus.Ready, 20_000);
} }