feat(bot): userbot (selfbot) mode — voice conversation on one user account (stage 1)

A user account is the only kind Discord lets Go Live, so add a userbot run mode:
when DISCORD_BOT_TOKEN is absent but DISCORD_SELFBOT_TOKEN is set, the app runs
as a userbot instead of the normal slash-command bot.

Stage 1 (conversation): log in with discord.js-selfbot-v13, auto-join
DISCORD_VOICE_CHANNEL_ID on startup (or "!자비스 join" when unset), listen via the
selfbot VoiceReceiver (pcm), transcribe+reply through the bridge, and speak the
reply back with playAudio. Shared PCM/WAV helpers extracted to audio.ts.
run-bot.sh now starts in either userbot or normal mode. Go-Live broadcast +
broadcast-coupled routing land in stage 2 on the same voice connection.

ToS note: selfbots violate Discord ToS; burner account only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
javis-bot
2026-06-11 11:08:39 +09:00
parent 03375a77d0
commit 921a757371
5 changed files with 226 additions and 10 deletions

142
bot/src/userbot.ts Normal file
View File

@@ -0,0 +1,142 @@
/**
* Userbot (selfbot) entry — runs the whole assistant on a single USER account
* token, which is the only kind of account Discord lets Go Live (screen-share).
*
* Stage 1 (this module): log in, join a voice channel (auto from
* DISCORD_VOICE_CHANNEL_ID, or via the "!자비스 join/leave" text command), listen
* to users (selfbot VoiceReceiver -> Whisper via the bridge), and speak replies
* back (TTS -> playAudio). Go-Live broadcast + broadcast-coupled search routing
* are wired in Stage 2 on the SAME voice connection (createStreamConnection).
*
* ToS WARNING: automating a user account ("selfbot") violates Discord's ToS and
* can get the account banned. Use a throwaway/burner account only.
*/
import { Readable } from "node:stream";
import { config } from "./config.ts";
import { converse, decodeWav } from "./bridge.ts";
import { DISCORD_RATE, pcm16MonoToWav, stereoToMono } from "./audio.ts";
// The selfbot lib is an optional native dependency (same as the streamer path),
// loaded dynamically so the normal-bot build runs without it. Its types are
// loose, so the voice surface is treated as `any`.
type AnyClient = any;
type AnyConnection = any;
const listening = new Set<string>();
let connection: AnyConnection = null;
async function loadSelfbot(): Promise<any> {
try {
return await import("discord.js-selfbot-v13");
} catch (e) {
throw new Error(
"유저봇 의존성이 없습니다. 설치: bun add discord.js-selfbot-v13\n" +
`원본 오류: ${(e as Error).message}`,
);
}
}
/** Capture one user's utterance, send it to the brain, speak the reply back. */
async function handleUtterance(userId: string, conn: AnyConnection): Promise<void> {
// PCM mode: the receiver decodes Opus to 48 kHz stereo s16le for us.
const pcmStream: Readable = conn.receiver.createStream(userId, {
mode: "pcm",
end: "silence",
});
const chunks: Buffer[] = [];
pcmStream.on("data", (c: Buffer) => chunks.push(c));
await new Promise<void>((resolve) => pcmStream.once("end", () => resolve()));
if (!chunks.length) return;
const mono = stereoToMono(Buffer.concat(chunks));
// Ignore blips shorter than ~300ms (likely noise / key clicks).
if (mono.length < DISCORD_RATE * 0.3 * 2) return;
const wav = pcm16MonoToWav(mono, DISCORD_RATE);
try {
// broadcasting is wired in Stage 2 (Go-Live state); until then it is off so
// real-time search uses the Gemini path.
const result = await converse(wav, false);
if (result.transcript) {
console.log(`🗣️ ${result.transcript}\n🤖 ${result.reply}`);
}
const audio = decodeWav(result.audio_b64);
if (audio) {
try {
conn.playAudio(Readable.from(audio));
} catch (e) {
console.error("[userbot] playAudio failed:", e);
}
}
} catch (err) {
console.error("[userbot] converse failed:", err);
}
}
async function joinAndListen(client: AnyClient, channelId: string): Promise<void> {
const channel: any = await client.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isVoice?.()) {
console.error(`[userbot] voice channel ${channelId} not found / not a voice channel`);
return;
}
connection = await client.voice.joinChannel(channel, {
selfMute: false,
selfDeaf: false,
selfVideo: false,
});
console.log(`🎙️ 유저봇이 '${channel.name}' 음성채널에 참여했습니다.`);
connection.on("speaking", (user: any, speaking: any) => {
const uid = user?.id;
if (!uid || !speaking?.bitfield) return; // bitfield 0 = stopped speaking
if (listening.has(uid)) return;
listening.add(uid);
handleUtterance(uid, connection).finally(() => listening.delete(uid));
});
}
function leave(): void {
try {
connection?.disconnect?.();
} catch {
/* already gone */
}
connection = null;
listening.clear();
}
export async function runUserbot(): Promise<void> {
if (!config.selfbotToken) {
throw new Error("DISCORD_SELFBOT_TOKEN이 설정되지 않았습니다 (.env). 버너 계정 토큰을 넣어주세요.");
}
const selfbot = await loadSelfbot();
const client = new selfbot.Client();
client.on("ready", async () => {
console.log(`✓ 유저봇 로그인: ${client.user?.tag}`);
if (config.autoJoinChannelId) {
await joinAndListen(client, config.autoJoinChannelId).catch((e) =>
console.error("[userbot] auto-join failed:", e),
);
} else {
console.log('[userbot] DISCORD_VOICE_CHANNEL_ID 미설정 — "!자비스 join" 명령을 기다립니다.');
}
});
// Text-command control (userbots cannot use slash commands). The command
// joins the caller's current voice channel, or leaves.
client.on("messageCreate", async (msg: any) => {
if (msg.author?.id !== client.user?.id && !config.autoJoinChannelId) {
// Only the account owner drives it when there's no fixed channel.
}
const content = (msg.content || "").trim();
if (content === "!자비스 join" || content === "!jarvis join") {
const ch = msg.member?.voice?.channel || msg.author?.voice?.channel;
if (ch) await joinAndListen(client, ch.id).catch((e) => console.error("[userbot] join cmd:", e));
} else if (content === "!자비스 leave" || content === "!jarvis leave") {
leave();
}
});
await client.login(config.selfbotToken);
}