From ddebdd7542d319a97d9834a003eb7ae615f14ee2 Mon Sep 17 00:00:00 2001 From: javis-bot Date: Sat, 13 Jun 2026 22:37:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(stream):=20single-account=20Go-Live=20?= =?UTF-8?q?=E2=80=94=20broadcast=20on=20the=20conversation's=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bot/src/broadcast.ts | 15 ++++- bot/src/stream/index.ts | 15 +++++ bot/src/stream/selfbot.ts | 113 +++++++++++++++++++++----------------- bot/src/voice.ts | 36 +++++++++++- 4 files changed, 126 insertions(+), 53 deletions(-) diff --git a/bot/src/broadcast.ts b/bot/src/broadcast.ts index 2409f97..11428c5 100644 --- a/bot/src/broadcast.ts +++ b/bot/src/broadcast.ts @@ -31,7 +31,10 @@ export function peekStreamer(guildId: string): ScreenStreamer | undefined { return streamers.get(guildId); } -type BroadcastSession = Pick; +type BroadcastSession = Pick< + VoiceSession, + "getBroadcasting" | "onBroadcastAction" | "getSharedSession" +>; /** * Wire a voice session to a streamer (no auto-start). Pure (no config / registry @@ -107,8 +110,14 @@ export async function maybeCoupleBroadcast( ): Promise { if (!config.screenBrowser) return; const streamer = await getStreamer(ctx.guildId); - wireBroadcast(session, streamer, ctx); - void autoStartBroadcast(streamer, ctx); + // 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). */ diff --git a/bot/src/stream/index.ts b/bot/src/stream/index.ts index 36fc0f1..0f06a3b 100644 --- a/bot/src/stream/index.ts +++ b/bot/src/stream/index.ts @@ -8,11 +8,26 @@ */ 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 { guildId: string; voiceChannelId: string; /** Post an image to the invoking text channel (used by the screenshot backend). */ postImage?: (png: Buffer, name: string) => Promise; + /** 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 { diff --git a/bot/src/stream/selfbot.ts b/bot/src/stream/selfbot.ts index b7a68b2..aa74424 100644 --- a/bot/src/stream/selfbot.ts +++ b/bot/src/stream/selfbot.ts @@ -35,6 +35,26 @@ export class SelfbotStreamer implements ScreenStreamer { // local "we kicked off ffmpeg" guess. private starting = 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) {} @@ -141,20 +161,18 @@ export class SelfbotStreamer implements ScreenStreamer { } const dedicatedStreamAccount = !!this.config.streamToken && this.config.streamToken !== this.config.selfbotToken; - // CRITICAL: in single-account userbot mode (no normal-bot token, no dedicated - // stream token) we must NOT start the broadcast. The broadcaster logs in as a - // SECOND session of the conversation account and joins voice with - // self_deaf:true (the streaming lib always deafens itself). Voice state is - // per-account, so that deafens the CONVERSATION session too and the bot stops - // hearing the user (STT breaks). The Go-Live can't connect on a shared account - // anyway, so refuse early — protecting the conversation is more important than - // a broadcast that cannot work. A dedicated DISCORD_STREAM_TOKEN removes this. - if (!dedicatedStreamAccount && !this.config.botToken) { + // Single-account userbot: broadcast on the conversation's OWN session — the + // Go-Live stream is a SEPARATE stream connection on the same session (exactly + // like a real Discord client), so it never deafens or fights the conversation + // voice. Only when there is neither a shared session nor a dedicated account + // is the broadcast impossible: a second login would deafen the conversation, + // so refuse to protect it. + const shared = ctx.getSharedSession?.() ?? null; + if (!shared && !dedicatedStreamAccount && !this.config.botToken) { console.warn( - "[selfbot] ⚠️ 방송 전용 DISCORD_STREAM_TOKEN 미설정 — 단일 계정 유저봇에서는 방송이 대화 음성을 " + - "끊으므로(self_deaf) 시작하지 않습니다. 별도 버너 계정 토큰(DISCORD_STREAM_TOKEN)을 설정하세요.", + "[selfbot] ⚠️ 공유 세션도 방송 전용 토큰도 없어 방송을 시작하지 않습니다 (대화 음성 보호).", ); - return "방송 전용 계정(DISCORD_STREAM_TOKEN)이 없어 대화 음성 보호를 위해 방송을 시작하지 않았습니다."; + return "방송을 시작할 수 없습니다 (대화 세션 공유 불가 + 전용 계정 없음)."; } if (!ctx.voiceChannelId) { return "셀프봇 송출은 음성 채널 안에서 호출해야 합니다."; @@ -180,21 +198,36 @@ export class SelfbotStreamer implements ScreenStreamer { const { Streamer, prepareStream, playStream } = vs; signal.throwIfAborted(); - streamer = this.streamer = new Streamer(new selfbot.Client()); - await streamer.client.login(streamToken); - signal.throwIfAborted(); - - // Act like a person, not a bot: take a breath after coming online before - // navigating into the voice channel, then settle in for a few seconds - // before hitting "Go Live". Randomised so the cadence isn't - // fingerprintable. throwIfAborted() after each pause unwinds into the - // catch below if stop() lands mid-wait, so we never join/go-live on a - // torn-down streamer. - await this.humanPause(2500, 4500, signal); - signal.throwIfAborted(); - await streamer.joinVoice(ctx.guildId, ctx.voiceChannelId); - await this.humanPause(6000, 10000, signal); - signal.throwIfAborted(); + if (shared) { + // Ride the conversation's existing session: create the Go-Live STREAM + // connection on it (playStream below calls createStream against this + // voiceConnection) — no second login / voice join, which would deafen or + // fight the conversation. type/botId/session_id are required by the + // stream-create signalling. + this.ownsClient = false; + streamer = this.streamer = new Streamer(shared.client); + (streamer as any)._voiceConnection = { + guildId: shared.guildId, + channelId: shared.channelId, + session_id: shared.sessionId, + type: "guild", + botId: shared.botId, + }; + } 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)); @@ -333,12 +366,7 @@ export class SelfbotStreamer implements ScreenStreamer { } catch { /* ignore */ } - try { - streamer?.leaveVoice?.(); - streamer?.client?.destroy?.(); - } catch { - /* ignore */ - } + this.teardownStreamer(streamer); if (this.capture === capture) this.capture = null; if (this.keepalive === keepalive) this.keepalive = null; if (this.helper === helper) this.helper = null; @@ -367,10 +395,7 @@ export class SelfbotStreamer implements ScreenStreamer { try { capture?.kill("SIGKILL"); } catch { /* ignore */ } try { keepalive?.stop(); } catch { /* ignore */ } try { helper?.kill(); } catch { /* ignore */ } - try { - streamer?.leaveVoice?.(); - streamer?.client?.destroy?.(); - } catch { /* ignore */ } + this.teardownStreamer(streamer); if (this.controller === controller) { if (this.capture === capture) this.capture = null; if (this.keepalive === keepalive) this.keepalive = null; @@ -405,12 +430,7 @@ export class SelfbotStreamer implements ScreenStreamer { } catch { /* ignore */ } - try { - streamer?.leaveVoice?.(); - streamer?.client?.destroy?.(); - } catch { - /* ignore */ - } + this.teardownStreamer(streamer); // Only release the lock / clear instance state if WE are still the // current attempt. If a concurrent stop()+start() already replaced the // controller, a newer start() owns the lock — clearing it here would @@ -450,12 +470,7 @@ export class SelfbotStreamer implements ScreenStreamer { /* ignore */ } this.helper = null; - try { - this.streamer?.leaveVoice?.(); - this.streamer?.client?.destroy?.(); - } catch { - /* ignore */ - } + this.teardownStreamer(this.streamer); this.streamer = null; this.live = false; this.starting = false; diff --git a/bot/src/voice.ts b/bot/src/voice.ts index d39a815..d1c5e10 100644 --- a/bot/src/voice.ts +++ b/bot/src/voice.ts @@ -86,12 +86,32 @@ export class VoiceSession { /** Apply a broadcast directive the brain requested (start/stop the stream). */ onBroadcastAction?: (action: "start" | "stop") => void | Promise; + /** 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) { 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({ channelId: channel.id, guildId: channel.guild.id, - adapterCreator: channel.guild.voiceAdapterCreator, + adapterCreator, selfDeaf: false, // we need to hear users selfMute: false, }); @@ -102,6 +122,20 @@ export class VoiceSession { 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 { await entersState(this.connection, VoiceConnectionStatus.Ready, 20_000); }