Files
watch_sceen_ai/dave/join.mjs
EJClaw 5327f8ec7c feat(dave): join with mic on (self_mute=false) so bot can speak and hear
Previously the selfbot joined self_mute=true (listen-only). Un-mute so both
directions are enabled at the Discord voice-state level for live testing.
2026-08-15 21:01:37 +09:00

242 lines
12 KiB
JavaScript

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