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

40
bot/src/audio.ts Normal file
View File

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

View File

@@ -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"),

View File

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

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);
}

View File

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