Replace the user-token selfbot voice path with an official Discord bot using discord.js 14 + @discordjs/voice 0.19. The bot logs in with the stored testbot token, joins the target voice channel, passes the DAVE/MLS E2EE handshake, and receives per-user Opus audio via VoiceReceiver (the STT input path). ToS-safe. Live-verified: bot joined guild "사지방" / channel "일반" and reached Ready. Selfbot (gate.mjs/join.mjs) kept only for the deferred screenshare-video track, which official bots cannot receive. Docs updated (README/PLAN); M1 done on bot path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
152 lines
6.7 KiB
JavaScript
152 lines
6.7 KiB
JavaScript
// Official Discord BOT voice joiner (replaces the selfbot join.mjs).
|
|
//
|
|
// Why this exists: the project migrated off the user-token selfbot path onto an
|
|
// official bot application. For the voice loop (STT input) this is the fully
|
|
// supported, ToS-safe path — @discordjs/voice lets a bot JOIN a voice channel,
|
|
// pass the DAVE/MLS E2EE handshake (via @snazzah/davey, handled internally), and
|
|
// RECEIVE per-user Opus audio. Only screenshare VIDEO receive still requires a
|
|
// selfbot, and video ("눈") is deferred, so nothing here needs a user token.
|
|
//
|
|
// This replicates M1 (join target channel, stay, detect speakers) and, for free,
|
|
// gives partial M2: it decodes each speaker's Opus to PCM and counts frames — the
|
|
// exact stream the STT stage will consume.
|
|
//
|
|
// Usage:
|
|
// node bot.mjs # join and stay until killed
|
|
// RUN_MS=15000 node bot.mjs # join, hold 15s, then leave (for verification)
|
|
//
|
|
// Requires: the bot must be INVITED to the target guild with the "Connect" and
|
|
// "Speak" voice permissions. See dave/README or run `node bot.mjs --invite`.
|
|
|
|
import fs from 'node:fs';
|
|
import {
|
|
Client,
|
|
GatewayIntentBits,
|
|
} from 'discord.js';
|
|
import {
|
|
joinVoiceChannel,
|
|
getVoiceConnection,
|
|
entersState,
|
|
VoiceConnectionStatus,
|
|
EndBehaviorType,
|
|
} from '@discordjs/voice';
|
|
import prism from 'prism-media';
|
|
|
|
// ---------- config ----------
|
|
function loadEnvFile() {
|
|
try {
|
|
return Object.fromEntries(
|
|
fs.readFileSync(new URL('../.env', import.meta.url), 'utf8')
|
|
.split('\n').filter(l => l && !l.startsWith('#') && l.includes('='))
|
|
.map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; })
|
|
);
|
|
} catch { return {}; }
|
|
}
|
|
const env = loadEnvFile();
|
|
const TOKEN = process.env.DISCORD_BOT_TOKEN || env.DISCORD_BOT_TOKEN;
|
|
const GUILD_ID = process.env.GUILD_ID || env.GUILD_ID || '1352269198297923648';
|
|
const CHANNEL_ID = process.env.CHANNEL_ID || env.CHANNEL_ID || '1352269198914621465';
|
|
const RUN_MS = process.env.RUN_MS != null ? Number(process.env.RUN_MS) : 0; // 0 = stay forever
|
|
|
|
const t0 = Date.now();
|
|
const log = (...a) => console.log(`[+${String(Date.now() - t0).padStart(6)}ms]`, ...a);
|
|
|
|
// ---------- invite URL helper ----------
|
|
// A bot cannot add itself to a server; a server admin must click an OAuth2 invite
|
|
// URL once. Print it so the user can authorise the bot with voice permissions.
|
|
if (process.argv.includes('--invite')) {
|
|
const APP_ID = process.env.DISCORD_APP_ID || env.DISCORD_APP_ID || '';
|
|
// permissions: Connect(1<<20) | Speak(1<<21) | UseVAD(1<<25) | ViewChannel(1<<10)
|
|
const perms = (1n << 20n) | (1n << 21n) | (1n << 25n) | (1n << 10n);
|
|
if (!APP_ID) { console.error('set DISCORD_APP_ID (application/client id) in .env to build the invite URL'); process.exit(2); }
|
|
console.log(`https://discord.com/oauth2/authorize?client_id=${APP_ID}&scope=bot&permissions=${perms}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (!TOKEN) { console.error('no DISCORD_BOT_TOKEN in env or ../.env'); process.exit(2); }
|
|
|
|
// ---------- client ----------
|
|
const client = new Client({
|
|
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
|
|
});
|
|
|
|
const perUser = new Map(); // userId -> { opusPackets, pcmFrames }
|
|
|
|
let leaving = false;
|
|
function leaveAndExit(code = 0) {
|
|
if (leaving) return;
|
|
leaving = true;
|
|
try { getVoiceConnection(GUILD_ID)?.destroy(); } catch {}
|
|
const summary = [...perUser.entries()].map(([u, s]) => `${u}:opus=${s.opusPackets},pcm=${s.pcmFrames}`);
|
|
log(`leaving. speakers heard: ${summary.length ? summary.join(' ') : '(none)'}`);
|
|
try { client.destroy(); } catch {}
|
|
setTimeout(() => process.exit(code), 300);
|
|
}
|
|
process.on('SIGINT', () => { log('SIGINT'); leaveAndExit(0); });
|
|
process.on('SIGTERM', () => { log('SIGTERM'); leaveAndExit(0); });
|
|
|
|
// Hard time-box ceiling, armed at startup regardless of handshake state.
|
|
if (RUN_MS > 0) setTimeout(() => { log(`RUN_MS=${RUN_MS} hard ceiling elapsed — leaving`); leaveAndExit(0); }, RUN_MS);
|
|
|
|
client.once('clientReady', async () => {
|
|
log(`logged in as ${client.user.tag} (${client.user.id})`);
|
|
let guild, channel;
|
|
try {
|
|
guild = await client.guilds.fetch(GUILD_ID);
|
|
channel = await guild.channels.fetch(CHANNEL_ID);
|
|
} catch (e) {
|
|
log(`FATAL: cannot access guild/channel — is the bot invited to guild ${GUILD_ID}? (${e.message})`);
|
|
log('run `node bot.mjs --invite` and have a server admin authorise the bot, then retry.');
|
|
return leaveAndExit(1);
|
|
}
|
|
if (!channel || !channel.isVoiceBased()) { log(`FATAL: channel ${CHANNEL_ID} is not a voice channel`); return leaveAndExit(1); }
|
|
|
|
log(`joining voice: guild="${guild.name}" channel="${channel.name}"`);
|
|
const connection = joinVoiceChannel({
|
|
channelId: CHANNEL_ID,
|
|
guildId: GUILD_ID,
|
|
adapterCreator: guild.voiceAdapterCreator,
|
|
selfDeaf: false, // MUST be false to receive audio (the STT input path)
|
|
selfMute: false, // false so we can also speak later (M5 TTS)
|
|
});
|
|
|
|
try {
|
|
await entersState(connection, VoiceConnectionStatus.Ready, 20_000);
|
|
} catch (e) {
|
|
log(`FATAL: voice connection did not become Ready in 20s (${e.message})`);
|
|
return leaveAndExit(1);
|
|
}
|
|
log(`✅ JOINED & READY. channel=${CHANNEL_ID} — staying connected, listening for speakers…`);
|
|
|
|
// ---------- receive path (partial M2) ----------
|
|
const receiver = connection.receiver;
|
|
receiver.speaking.on('start', (userId) => {
|
|
if (!perUser.has(userId)) perUser.set(userId, { opusPackets: 0, pcmFrames: 0 });
|
|
log(`SPEAKING start user=${userId}`);
|
|
const opusStream = receiver.subscribe(userId, {
|
|
end: { behavior: EndBehaviorType.AfterSilence, duration: 500 },
|
|
});
|
|
// Decode Opus -> 48kHz stereo s16le PCM (what faster-whisper will consume, downsampled to 16k in M3).
|
|
const decoder = new prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 });
|
|
opusStream.on('data', () => { perUser.get(userId).opusPackets++; });
|
|
opusStream.pipe(decoder);
|
|
decoder.on('data', () => {
|
|
const s = perUser.get(userId);
|
|
s.pcmFrames++;
|
|
if (s.pcmFrames === 1 || s.pcmFrames % 200 === 0) log(`rtp: user=${userId} opus=${s.opusPackets} pcm=${s.pcmFrames}`);
|
|
});
|
|
decoder.on('error', (e) => log(`decode error user=${userId}: ${e.message}`));
|
|
});
|
|
|
|
connection.on(VoiceConnectionStatus.Disconnected, () => {
|
|
log('voice: disconnected — attempting to resume…');
|
|
Promise.race([
|
|
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
|
|
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
|
|
]).catch(() => { log('voice: could not resume, leaving'); leaveAndExit(0); });
|
|
});
|
|
});
|
|
|
|
client.on('error', (e) => log('client error', e.message));
|
|
client.login(TOKEN).catch((e) => { log(`FATAL: login failed — ${e.message}`); process.exit(1); });
|