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:
40
bot/src/audio.ts
Normal file
40
bot/src/audio.ts
Normal 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;
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
142
bot/src/userbot.ts
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user