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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user