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:
@@ -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). */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user