// M1 — persistent selfbot voice join (builds on the proven gate.mjs handshake). // // Difference from gate.mjs: this does NOT leave after collecting evidence. It // joins the target voice channel, completes the DAVE/MLS E2EE membership, then // STAYS connected and reports: // * who is speaking (op5 SPEAKING -> maps audio SSRC to a user id) // * incoming UDP/RTP packets per SSRC (the media path the STT stage will read) // // This is the foundation for M2 (decrypt the incoming Opus and feed STT). No // audio is transmitted yet. // // Usage: // node join.mjs # join and stay until killed // RUN_MS=15000 node join.mjs # join, hold 15s, then leave (for verification) import WebSocket from 'ws'; import dgram from 'node:dgram'; import fs from 'node:fs'; import * as davey from '@snazzah/davey'; const env = 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()]; }) ); const TOKEN = env.DISCORD_SELFBOT_TOKEN; const GUILD_ID = process.env.GUILD_ID || '1352269198297923648'; const CHANNEL_ID = process.env.CHANNEL_ID || '1352269198914621465'; const SELF_ID = process.env.SELF_ID || '1513862586112671786'; const DAVE_VER = process.env.DAVE_VER != null ? Number(process.env.DAVE_VER) : davey.DAVE_PROTOCOL_VERSION; const RUN_MS = process.env.RUN_MS != null ? Number(process.env.RUN_MS) : 0; // 0 = stay forever if (!TOKEN) { console.error('no DISCORD_SELFBOT_TOKEN in .env'); process.exit(2); } const t0 = Date.now(); const log = (...a) => console.log(`[+${String(Date.now() - t0).padStart(6)}ms]`, ...a); console.log(`davey VERSION=${davey.VERSION} DAVE_PROTOCOL_VERSION=${davey.DAVE_PROTOCOL_VERSION}`); log(`join start: channel=${CHANNEL_ID} guild=${GUILD_ID} dave=${DAVE_VER}`); let mainWs, voiceWs, udp; let daveSession = null; let mlsReady = false; let discoverySelected = false; const knownUsers = new Set([SELF_ID]); const ssrcToUser = new Map(); // audio ssrc -> user id const rtpCount = new Map(); // ssrc -> packet count const voiceState = { session_id: null, token: null, endpoint: null, ssrc: null, ip: null, port: null, mode: null }; let leaving = false; function leaveAndExit(code = 0) { if (leaving) return; // idempotent: hard ceiling + ready timer + signals must not double-fire leaving = true; try { mainWs?.send(JSON.stringify({ op: 4, d: { guild_id: GUILD_ID, channel_id: null, self_mute: true, self_deaf: true } })); } catch {} setTimeout(() => { try { voiceWs?.close(); } catch {} try { udp?.close(); } catch {} try { mainWs?.close(); } catch {} log(`leaving. speakers seen: ${JSON.stringify([...ssrcToUser.entries()])}, rtp counts: ${JSON.stringify([...rtpCount.entries()])}`); process.exit(code); }, 400); } 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. Without // this, a partial join (e.g. DAVE/MLS never completes op29/op30 so announceReady // never fires) would run the selfbot forever — a guardrail hole for a live test. if (RUN_MS > 0) setTimeout(() => { log(`RUN_MS=${RUN_MS} hard ceiling elapsed — leaving`); leaveAndExit(0); }, RUN_MS); // ---------- MAIN GATEWAY ---------- mainWs = new WebSocket('wss://gateway.discord.gg/?v=10&encoding=json'); let mainHb; mainWs.on('open', () => log('main gw: open')); mainWs.on('message', (raw) => { const p = JSON.parse(raw.toString()); if (p.op === 10) { mainHb = setInterval(() => { try { mainWs.send(JSON.stringify({ op: 1, d: null })); } catch {} }, p.d.heartbeat_interval); mainWs.send(JSON.stringify({ op: 2, d: { token: TOKEN, capabilities: 16381, properties: { os: 'Linux', browser: 'Chrome', device: '', system_locale: 'en-US', browser_user_agent: 'Mozilla/5.0', browser_version: '124.0', os_version: '', release_channel: 'stable', client_build_number: 300000 }, compress: false, presence: { status: 'invisible', since: 0, activities: [], afk: false }, }})); log('main gw: sent IDENTIFY'); } else if (p.op === 0) { if (p.t === 'READY') { log(`main gw: READY as ${p.d.user?.username} (${p.d.user?.id})`); mainWs.send(JSON.stringify({ op: 4, d: { guild_id: GUILD_ID, channel_id: CHANNEL_ID, self_mute: false, self_deaf: false } })); log('main gw: sent Voice State Update (join, mute=false + deaf=false so we can both speak and hear)'); } else if (p.t === 'VOICE_STATE_UPDATE' && p.d.user_id === SELF_ID && p.d.session_id) { voiceState.session_id = p.d.session_id; log('VOICE_STATE_UPDATE session_id acquired'); maybeConnectVoice(); } else if (p.t === 'VOICE_SERVER_UPDATE') { voiceState.token = p.d.token; voiceState.endpoint = p.d.endpoint; log(`VOICE_SERVER_UPDATE endpoint=${p.d.endpoint}`); maybeConnectVoice(); } } }); mainWs.on('close', (c, r) => { log(`main gw: close ${c} ${r}`); clearInterval(mainHb); }); mainWs.on('error', (e) => log('main gw error', e.message)); // ---------- VOICE GATEWAY ---------- function maybeConnectVoice() { if (voiceWs || !voiceState.session_id || !voiceState.token || !voiceState.endpoint) return; const url = `wss://${voiceState.endpoint}/?v=8`; log(`voice gw: connecting ${url}`); voiceWs = new WebSocket(url); let voiceHb, lastSeq = null; voiceWs.on('open', () => { voiceWs.send(JSON.stringify({ op: 0, d: { server_id: GUILD_ID, user_id: SELF_ID, session_id: voiceState.session_id, token: voiceState.token, max_dave_protocol_version: DAVE_VER, }})); log(`voice gw: sent IDENTIFY (max_dave_protocol_version=${DAVE_VER})`); }); voiceWs.on('message', (raw, isBinary) => { if (isBinary) { const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); const seq = buf.readUInt16BE(0), op = buf.readUInt8(2), payload = buf.subarray(3); lastSeq = seq; onDaveBinary(op, payload, seq); return; } handleVoiceJson(JSON.parse(raw.toString())); }); voiceWs.on('close', (c, r) => { log(`voice gw: CLOSE code=${c} reason="${r?.toString() || ''}"`); clearInterval(voiceHb); if (c === 4017) { log('FATAL: close 4017 (DAVE rejected)'); leaveAndExit(1); } }); voiceWs.on('error', (e) => log('voice gw error', e.message)); function handleVoiceJson(p) { switch (p.op) { case 8: voiceHb = setInterval(() => { try { voiceWs.send(JSON.stringify({ op: 3, d: { t: Date.now(), seq_ack: lastSeq ?? 0 } })); } catch {} }, p.d.heartbeat_interval); log(`voice gw: HELLO hb=${p.d.heartbeat_interval}`); break; case 2: // READY voiceState.ssrc = p.d.ssrc; voiceState.ip = p.d.ip; voiceState.port = p.d.port; log(`voice gw: READY ssrc=${p.d.ssrc} udp=${p.d.ip}:${p.d.port} modes=${JSON.stringify(p.d.modes)}`); doUdpDiscoveryAndSelect(p.d); break; case 4: // SESSION_DESCRIPTION voiceState.daveVer = p.d?.dave_protocol_version ?? null; log(`voice gw: SESSION_DESCRIPTION dave=${voiceState.daveVer} mode=${p.d?.mode}`); announceReady(); break; case 5: // SPEAKING — maps a user to their audio ssrc if (p.d?.user_id && p.d?.ssrc != null) { ssrcToUser.set(p.d.ssrc, p.d.user_id); knownUsers.add(p.d.user_id); log(`voice gw: SPEAKING user=${p.d.user_id} ssrc=${p.d.ssrc} flags=${p.d.speaking}`); } break; case 11: for (const u of (p.d.user_ids || [])) knownUsers.add(u); log(`voice gw: op11 clients_connect ${JSON.stringify(p.d.user_ids)}`); break; case 13: if (p.d?.user_id) { knownUsers.delete(p.d.user_id); log(`voice gw: op13 client_disconnect ${p.d.user_id}`); } break; case 20: if (p.d.user_id) knownUsers.add(p.d.user_id); break; case 21: try { voiceWs.send(JSON.stringify({ op: 23, d: { transition_id: p.d.transition_id } })); } catch {} log('voice gw: DAVE op21 prepare_transition (acked)'); break; case 22: log('voice gw: DAVE op22 execute_transition'); break; default: /* quiet */ break; } } function onDaveBinary(op, payload) { try { switch (op) { case 25: { if (!daveSession) daveSession = new davey.DAVESession(voiceState.daveVer || DAVE_VER, SELF_ID, CHANNEL_ID); daveSession.setExternalSender(payload); const kp = daveSession.getSerializedKeyPackage(); voiceWs.send(Buffer.concat([Buffer.from([26]), kp]), { binary: true }); log(`voice gw: sent DAVE op26 key_package len=${kp.length}`); break; } case 27: { const res = daveSession.processProposals(payload.readUInt8(0), payload.subarray(1), Array.from(knownUsers)); if (res && res.commit) { const parts = [Buffer.from([28]), res.commit]; if (res.welcome) parts.push(res.welcome); voiceWs.send(Buffer.concat(parts), { binary: true }); log(`voice gw: sent DAVE op28 commit_welcome`); } break; } case 29: daveSession.processCommit(payload.subarray(2)); mlsReady = daveSession.ready; log(`voice gw: op29 commit -> ready=${mlsReady}`); announceReady(); break; case 30: daveSession.processWelcome(payload.subarray(2)); mlsReady = daveSession.ready; log(`voice gw: op30 welcome -> ready=${mlsReady}`); announceReady(); break; default: break; } } catch (e) { log(`voice gw: DAVE op${op} error: ${e.message}`); } } let announced = false; function announceReady() { if (announced) return; const daveOk = voiceState.daveVer === 0 || mlsReady; if (voiceState.ssrc != null && voiceState.daveVer != null && daveOk && discoverySelected) { announced = true; log(`✅ JOINED & READY. channel=${CHANNEL_ID} dave=${voiceState.daveVer} mlsReady=${mlsReady} privacyCode=${daveSession?.voicePrivacyCode || 'n/a'}`); log(' staying connected, listening for speakers…'); // time-box is owned by the startup hard-ceiling timer (armed regardless of ready state) } } function doUdpDiscoveryAndSelect(ready) { udp = dgram.createSocket('udp4'); const disc = Buffer.alloc(74); disc.writeUInt16BE(1, 0); disc.writeUInt16BE(70, 2); disc.writeUInt32BE(ready.ssrc, 4); udp.on('message', (msg) => { if (!discoverySelected) { discoverySelected = true; const ipEnd = msg.indexOf(0, 8); const ip = msg.subarray(8, ipEnd).toString(); const port = msg.readUInt16BE(msg.length - 2); const mode = (ready.modes || []).includes('aead_aes256_gcm_rtpsize') ? 'aead_aes256_gcm_rtpsize' : (ready.modes || []).includes('aead_xchacha20_poly1305_rtpsize') ? 'aead_xchacha20_poly1305_rtpsize' : (ready.modes || [])[0]; voiceState.mode = mode; voiceWs.send(JSON.stringify({ op: 1, d: { protocol: 'udp', data: { address: ip, port, mode }, codecs: [ { name: 'opus', type: 'audio', priority: 1000, payload_type: 120 }, ] }})); log(`voice gw: sent SELECT PROTOCOL (mode=${mode}) after UDP discovery ${ip}:${port}`); announceReady(); return; } // After selection: these are incoming SRTP media packets. Just tally per-SSRC // (decryption -> Opus -> PCM is M2). RTP: ssrc at bytes 8..11, pt = byte1 & 0x7f. if (msg.length >= 12) { const ssrc = msg.readUInt32BE(8); rtpCount.set(ssrc, (rtpCount.get(ssrc) || 0) + 1); const n = rtpCount.get(ssrc); if (n === 1 || n % 200 === 0) { const user = ssrcToUser.get(ssrc) || '?'; log(`rtp: ssrc=${ssrc} user=${user} pt=${msg.readUInt8(1) & 0x7f} count=${n}`); } } }); udp.on('error', (e) => log('udp error', e.message)); udp.send(disc, ready.port, ready.ip, (e) => { if (e) log('udp send err', e.message); else log('udp: sent IP discovery'); }); } }