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

@@ -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;