From 921a757371019fcbdf3163da6f87201d66606617 Mon Sep 17 00:00:00 2001 From: javis-bot Date: Thu, 11 Jun 2026 11:08:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(bot):=20userbot=20(selfbot)=20mode=20?= =?UTF-8?q?=E2=80=94=20voice=20conversation=20on=20one=20user=20account=20?= =?UTF-8?q?(stage=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bot/src/audio.ts | 40 +++++++++++++ bot/src/config.ts | 12 +++- bot/src/index.ts | 19 +++++- bot/src/userbot.ts | 142 +++++++++++++++++++++++++++++++++++++++++++++ docker/run-bot.sh | 23 ++++++-- 5 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 bot/src/audio.ts create mode 100644 bot/src/userbot.ts diff --git a/bot/src/audio.ts b/bot/src/audio.ts new file mode 100644 index 0000000..e3986d4 --- /dev/null +++ b/bot/src/audio.ts @@ -0,0 +1,40 @@ +/** + * Shared PCM/WAV helpers for the voice paths (normal-bot @discordjs/voice and + * the userbot/selfbot receiver). Discord delivers 48 kHz, 2-channel, 16-bit LE + * PCM either way, so the brain-bound WAV is built the same. + */ + +export const DISCORD_RATE = 48000; +export const DISCORD_CHANNELS = 2; + +/** Build a minimal PCM16 mono WAV around raw little-endian samples. */ +export function pcm16MonoToWav(pcm: Buffer, sampleRate: number): Buffer { + const header = Buffer.alloc(44); + const dataLen = pcm.length; + header.write("RIFF", 0); + header.writeUInt32LE(36 + dataLen, 4); + header.write("WAVE", 8); + header.write("fmt ", 12); + header.writeUInt32LE(16, 16); + header.writeUInt16LE(1, 20); // PCM + header.writeUInt16LE(1, 22); // mono + header.writeUInt32LE(sampleRate, 24); + header.writeUInt32LE(sampleRate * 2, 28); // byte rate (mono * 2 bytes) + header.writeUInt16LE(2, 32); // block align + header.writeUInt16LE(16, 34); // bits per sample + header.write("data", 36); + header.writeUInt32LE(dataLen, 40); + return Buffer.concat([header, pcm]); +} + +/** Downmix interleaved stereo PCM16 to mono PCM16. */ +export function stereoToMono(stereo: Buffer): Buffer { + const samples = Math.floor(stereo.length / 4); // 2 ch * 2 bytes + const mono = Buffer.alloc(samples * 2); + for (let i = 0; i < samples; i++) { + const l = stereo.readInt16LE(i * 4); + const r = stereo.readInt16LE(i * 4 + 2); + mono.writeInt16LE((l + r) >> 1, i * 2); + } + return mono; +} diff --git a/bot/src/config.ts b/bot/src/config.ts index c70c3cc..17950cc 100644 --- a/bot/src/config.ts +++ b/bot/src/config.ts @@ -17,10 +17,16 @@ function opt(name: string, fallback = ""): string { export type StreamBackend = "selfbot" | "novnc" | "screenshot" | "none"; export const config = { - // --- Normal Discord bot (voice I/O, slash commands) --- - botToken: req("DISCORD_BOT_TOKEN"), - appId: req("DISCORD_APP_ID"), + // --- Discord identity --- + // Normal bot token/appId are OPTIONAL: when absent (and a selfbot token is + // present) the app runs in userbot mode (one user account does voice + + // Go-Live). When present, the legacy normal-bot + slash-command path runs. + botToken: opt("DISCORD_BOT_TOKEN"), + appId: opt("DISCORD_APP_ID"), guildId: req("DISCORD_GUILD_ID"), + // Userbot auto-join: if set, the userbot joins this voice channel on startup; + // if empty, it waits for a text command (e.g. "!자비스 join") to join. + autoJoinChannelId: opt("DISCORD_VOICE_CHANNEL_ID"), // --- Python brain bridge --- bridgeUrl: opt("BRIDGE_URL", "http://127.0.0.1:8765"), diff --git a/bot/src/index.ts b/bot/src/index.ts index 056322b..fe96549 100644 --- a/bot/src/index.ts +++ b/bot/src/index.ts @@ -184,4 +184,21 @@ async function handleStatus(i: ChatInputCommandInteraction) { ); } -client.login(config.botToken); +// Mode select: a USER account is the only kind Discord lets Go Live, so when +// there's no normal-bot token but a selfbot token is present, run as a userbot +// (voice + broadcast on one user account). Otherwise run the legacy normal bot. +(async () => { + if (!config.botToken && config.selfbotToken) { + const { runUserbot } = await import("./userbot.ts"); + await runUserbot(); + } else if (config.botToken) { + await client.login(config.botToken); + } else { + throw new Error( + "DISCORD_BOT_TOKEN(일반 봇) 또는 DISCORD_SELFBOT_TOKEN(유저봇) 중 하나는 .env에 필요합니다.", + ); + } +})().catch((e) => { + console.error("[bot] fatal:", e); + process.exit(1); +}); diff --git a/bot/src/userbot.ts b/bot/src/userbot.ts new file mode 100644 index 0000000..96edaa2 --- /dev/null +++ b/bot/src/userbot.ts @@ -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(); +let connection: AnyConnection = null; + +async function loadSelfbot(): Promise { + 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 { + // 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((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 { + 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 { + 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); +} diff --git a/docker/run-bot.sh b/docker/run-bot.sh index 1ad801b..06ec238 100755 --- a/docker/run-bot.sh +++ b/docker/run-bot.sh @@ -7,11 +7,18 @@ set -e cd /app/bot -# The bot needs all three to log in AND register slash commands. If any is -# missing, wait instead of crash-looping (config.ts throws on a missing var). -if [ -z "${DISCORD_BOT_TOKEN:-}" ] || [ -z "${DISCORD_APP_ID:-}" ] || [ -z "${DISCORD_GUILD_ID:-}" ]; then - echo "[bot] DISCORD_BOT_TOKEN/DISCORD_APP_ID/DISCORD_GUILD_ID 중 일부 미설정 — 봇 대기 중." - echo "[bot] .env에 셋 다 넣고 'docker compose up -d' 하면 시작됩니다." +# Two run modes, both needing DISCORD_GUILD_ID: +# userbot : DISCORD_SELFBOT_TOKEN set (voice + Go-Live on one user account) +# normal : DISCORD_BOT_TOKEN + DISCORD_APP_ID set (slash commands) +# If neither is usable yet, wait instead of crash-looping so the rest of the +# stack (desktop, bridge, ollama) still runs. +USERBOT_OK=0; NORMAL_OK=0 +[ -n "${DISCORD_GUILD_ID:-}" ] && [ -n "${DISCORD_SELFBOT_TOKEN:-}" ] && USERBOT_OK=1 +[ -n "${DISCORD_GUILD_ID:-}" ] && [ -n "${DISCORD_BOT_TOKEN:-}" ] && [ -n "${DISCORD_APP_ID:-}" ] && NORMAL_OK=1 +if [ "$USERBOT_OK" = 0 ] && [ "$NORMAL_OK" = 0 ]; then + echo "[bot] Discord 자격증명 미설정 — 봇 대기 중." + echo "[bot] 유저봇: DISCORD_GUILD_ID + DISCORD_SELFBOT_TOKEN, 또는" + echo "[bot] 일반봇: DISCORD_GUILD_ID + DISCORD_BOT_TOKEN + DISCORD_APP_ID 를 .env에 넣고 're-up'." echo "[bot] (그동안 VNC 데스크톱 / 브릿지 / Ollama 는 정상 동작합니다.)" exec sleep infinity fi @@ -21,5 +28,9 @@ for i in $(seq 1 60); do curl -fsS "$BRIDGE/health" >/dev/null 2>&1 && break sleep 1 done -bun run register || echo "[bot] slash command registration failed (continuing)" +# Slash-command registration only applies to normal-bot mode (index.ts runs the +# normal bot whenever DISCORD_BOT_TOKEN is set; userbot mode has no slash cmds). +if [ "$NORMAL_OK" = 1 ]; then + bun run register || echo "[bot] slash command registration failed (continuing)" +fi exec bun run start