fix(bot): userbot voice via @discordjs/voice 0.19 + DAVE (selfbot voice handshake fix)

The selfbot library's native voice connection cannot complete Discord's current
DAVE/v8 voice handshake and times out (15s). Root cause: DAVE E2EE (mandated
~March 2026). The bundled @discordjs/voice 0.18.0 also lacked DAVE.

Fix: upgrade @discordjs/voice to 0.19.2 + add @snazzah/davey (the DAVE protocol
lib) and drive the selfbot client through @discordjs/voice's joinVoiceChannel /
receiver via the client's voiceAdapterCreator — i.e. reuse the normal-bot
VoiceSession with a user-account channel. Verified: the userbot now reaches a
READY voice connection (host test) and joins the channel in-container with no
timeout. This also fixes the normal-bot voice path under DAVE. Drops the
hand-rolled selfbot-native receiver (audio.ts).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
javis-bot
2026-06-11 18:19:10 +09:00
parent 921a757371
commit 835459111e
4 changed files with 29 additions and 129 deletions

View File

@@ -5,7 +5,9 @@
"": {
"name": "javis-bot",
"dependencies": {
"@discordjs/voice": "^0.18.0",
"@discordjs/voice": "^0.19.2",
"@snazzah/davey": "^0.1.11",
"debug": "^4.3.7",
"discord.js": "^14.16.3",
"dotenv": "^16.4.5",
"libsodium-wrappers": "^0.7.15",
@@ -45,7 +47,7 @@
"@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
"@discordjs/voice": ["@discordjs/voice@0.18.0", "", { "dependencies": { "@types/ws": "^8.5.12", "discord-api-types": "^0.37.103", "prism-media": "^1.3.5", "tslib": "^2.6.3", "ws": "^8.18.0" } }, "sha512-BvX6+VJE5/vhD9azV9vrZEt9hL1G+GlOdsQaVl5iv9n87fkXjf3cSwllhR3GdaUC8m6dqT8umXIWtn3yCu4afg=="],
"@discordjs/voice": ["@discordjs/voice@0.19.2", "", { "dependencies": { "@snazzah/davey": "^0.1.9", "@types/ws": "^8.18.1", "discord-api-types": "^0.38.41", "prism-media": "^1.3.5", "tslib": "^2.8.1", "ws": "^8.19.0" } }, "sha512-3yJ255e4ag3wfZu/DSxeOZK1UtnqNxnspmLaQetGT0pDkThNZoHs+Zg6dgZZ19JEVomXygvfHn9lNpICZuYtEA=="],
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
@@ -541,8 +543,6 @@
"@discordjs/rest/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
"@discordjs/voice/discord-api-types": ["discord-api-types@0.37.120", "", {}, "sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw=="],
"bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],

View File

@@ -11,7 +11,9 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@discordjs/voice": "^0.18.0",
"@discordjs/voice": "^0.19.2",
"@snazzah/davey": "^0.1.11",
"debug": "^4.3.7",
"discord.js": "^14.16.3",
"dotenv": "^16.4.5",
"libsodium-wrappers": "^0.7.15",

View File

@@ -1,40 +0,0 @@
/**
* 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

@@ -1,29 +1,26 @@
/**
* 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).
* Userbot (selfbot) entry — runs the 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).
* Voice goes through @discordjs/voice (>=0.19 + @snazzah/davey for Discord's
* DAVE E2EE) using the selfbot client's `voiceAdapterCreator`. The selfbot
* library's OWN native voice connection does NOT complete the current
* (DAVE/v8) handshake and times out, so we deliberately reuse the normal-bot
* VoiceSession (receive -> Whisper -> reply -> TTS playback) instead, just with
* a user-account channel.
*
* Stage 1 (this module): log in, join (auto from DISCORD_VOICE_CHANNEL_ID or via
* "!자비스 join"), converse by voice. Go-Live broadcast + broadcast-coupled
* search routing land in stage 2.
*
* 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 type { VoiceBasedChannel } from "discord.js";
import { config } from "./config.ts";
import { converse, decodeWav } from "./bridge.ts";
import { DISCORD_RATE, pcm16MonoToWav, stereoToMono } from "./audio.ts";
import { joinChannel, leaveGuild } from "./voice.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 {
@@ -36,73 +33,17 @@ async function loadSelfbot(): Promise<any> {
}
}
/** 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?.()) {
if (!channel || channel.isVoice?.() === false) {
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,
});
// The selfbot VoiceChannel is runtime-compatible with @discordjs/voice's
// joinVoiceChannel (it exposes id, guild.id and guild.voiceAdapterCreator).
const session = await joinChannel(channel as unknown as VoiceBasedChannel);
session.onTurn = ({ transcript, reply }) => console.log(`🗣️ ${transcript}\n🤖 ${reply}`);
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> {
@@ -123,18 +64,15 @@ export async function runUserbot(): Promise<void> {
}
});
// Text-command control (userbots cannot use slash commands). The command
// joins the caller's current voice channel, or leaves.
// Text-command control (userbots cannot use slash commands): join the
// caller's current voice channel, or leave.
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();
leaveGuild(config.guildId);
}
});