Add WhatsApp dashboard pairing flow

This commit is contained in:
Shannon Sands 2026-07-02 12:28:26 +10:00 committed by Teknium
parent 6015ee5d2a
commit 4f620a0bbc
6 changed files with 1485 additions and 41 deletions

View file

@ -100,7 +100,9 @@ try {
.slice(0, 16);
} catch {}
const PAIR_ONLY = args.includes('--pair-only');
const PAIR_JSON = args.includes('--pair-json');
const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat"
const WHATSAPP_DM_POLICY = String(process.env.WHATSAPP_DM_POLICY || 'open').trim().toLowerCase();
const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || '');
const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\n────────────\n';
const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined
@ -197,6 +199,23 @@ function normalizeWhatsAppId(value) {
return String(value).replace(':', '@');
}
function redactWhatsAppId(value) {
const raw = String(value || '').trim();
if (!raw) return '';
const [userPart, domainPart = ''] = raw.split('@', 2);
const bare = userPart.split(':', 1)[0];
const digits = bare.replace(/\D/g, '');
const suffix = digits ? digits.slice(-4) : bare.slice(-4);
return `${suffix ? `${suffix}` : '…'}${domainPart ? `@${domainPart}` : ''}`;
}
function emitDebugEvent(payload) {
if (!WHATSAPP_DEBUG) return;
try {
console.log(JSON.stringify({ event: 'debug', ...payload }));
} catch {}
}
function getMessageContent(msg) {
const content = msg?.message || {};
if (content.ephemeralMessage?.message) return content.ephemeralMessage.message;
@ -360,6 +379,13 @@ function rememberSentId(id) {
let sock = null;
let connectionState = 'disconnected';
function emitPairEvent(event) {
if (!PAIR_JSON) return;
try {
console.log(JSON.stringify({ ts: Date.now(), ...event }));
} catch {}
}
async function startSocket() {
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
const { version } = await fetchLatestBaileysVersion();
@ -387,9 +413,13 @@ async function startSocket() {
const { connection, lastDisconnect, qr } = update;
if (qr) {
console.log('\n📱 Scan this QR code with WhatsApp on your phone:\n');
qrcode.generate(qr, { small: true });
console.log('\nWaiting for scan...\n');
if (PAIR_JSON) {
emitPairEvent({ event: 'qr', qr });
} else {
console.log('\n📱 Scan this QR code with WhatsApp on your phone:\n');
qrcode.generate(qr, { small: true });
console.log('\nWaiting for scan...\n');
}
}
if (connection === 'close') {
@ -397,22 +427,39 @@ async function startSocket() {
connectionState = 'disconnected';
if (reason === DisconnectReason.loggedOut) {
console.log('❌ Logged out. Delete session and restart to re-authenticate.');
emitPairEvent({ event: 'error', error: 'logged_out', reason });
if (!PAIR_JSON) {
console.log('❌ Logged out. Delete session and restart to re-authenticate.');
}
process.exit(1);
} else {
// 515 = restart requested (common after pairing). Always reconnect.
if (reason === 515) {
console.log('↻ WhatsApp requested restart (code 515). Reconnecting...');
} else {
console.log(`⚠️ Connection closed (reason: ${reason}). Reconnecting in 3s...`);
emitPairEvent({ event: 'disconnected', reason });
if (!PAIR_JSON) {
if (reason === 515) {
console.log('↻ WhatsApp requested restart (code 515). Reconnecting...');
} else {
console.log(`⚠️ Connection closed (reason: ${reason}). Reconnecting in 3s...`);
}
}
setTimeout(startSocket, reason === 515 ? 1000 : 3000);
}
} else if (connection === 'open') {
connectionState = 'connected';
console.log('✅ WhatsApp connected!');
const connectedUser = sock?.user
? {
id: sock.user.id || null,
name: sock.user.name || sock.user.verifiedName || null,
}
: null;
emitPairEvent({ event: 'connected', user: connectedUser });
if (!PAIR_JSON) {
console.log('✅ WhatsApp connected!');
}
if (PAIR_ONLY) {
console.log('✅ Pairing complete. Credentials saved.');
if (!PAIR_JSON) {
console.log('✅ Pairing complete. Credentials saved.');
}
// Give Baileys a moment to flush creds, then exit cleanly
setTimeout(() => process.exit(0), 2000);
}
@ -484,24 +531,29 @@ async function startSocket() {
if (!msg.message) continue;
const chatId = msg.key.remoteJid;
if (WHATSAPP_DEBUG) {
try {
console.log(JSON.stringify({
event: 'upsert', type,
fromMe: !!msg.key.fromMe, chatId,
senderId: msg.key.participant || chatId,
messageKeys: Object.keys(msg.message || {}),
}));
} catch {}
}
const senderId = msg.key.participant || chatId;
const isGroup = chatId.endsWith('@g.us');
const senderNumber = senderId.replace(/@.*/, '');
emitDebugEvent({
stage: 'upsert',
type,
fromMe: !!msg.key.fromMe,
chatId: redactWhatsAppId(chatId),
senderId: redactWhatsAppId(senderId),
messageKeys: Object.keys(msg.message || {}),
});
// Handle fromMe messages based on mode
let fromOwner = false;
if (msg.key.fromMe) {
if (isGroup || chatId.includes('status')) continue;
if (isGroup || chatId.includes('status')) {
emitDebugEvent({
stage: 'ignored',
reason: isGroup ? 'from_me_group' : 'from_me_status',
chatId: redactWhatsAppId(chatId),
});
continue;
}
if (WHATSAPP_MODE === 'bot') {
// Bot mode: separate bot number. fromMe inbound is either
@ -546,7 +598,22 @@ async function startSocket() {
const myLid = (sock.user?.lid || '').replace(/:.*@/, '@').replace(/@.*/, '');
const chatNumber = chatId.replace(/@.*/, '');
const isSelfChat = (myNumber && chatNumber === myNumber) || (myLid && chatNumber === myLid);
if (!isSelfChat) continue;
emitDebugEvent({
stage: 'self_chat_check',
matched: !!isSelfChat,
chatId: redactWhatsAppId(chatId),
accountId: redactWhatsAppId(sock.user?.id),
accountLid: redactWhatsAppId(sock.user?.lid),
});
if (!isSelfChat) {
emitDebugEvent({
stage: 'ignored',
reason: 'self_chat_mismatch',
chatId: redactWhatsAppId(chatId),
senderId: redactWhatsAppId(senderId),
});
continue;
}
}
}
@ -567,7 +634,7 @@ async function startSocket() {
} catch {}
continue;
}
if (!matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
if (WHATSAPP_DM_POLICY !== 'pairing' && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
try {
console.log(JSON.stringify({
event: 'ignored',
@ -659,25 +726,39 @@ async function startSocket() {
// Ignore Hermes' own reply messages in self-chat mode to avoid loops.
if (msg.key.fromMe && ((REPLY_PREFIX && event.body.startsWith(REPLY_PREFIX)) || recentlySentIds.has(msg.key.id))) {
if (WHATSAPP_DEBUG) {
try { console.log(JSON.stringify({ event: 'ignored', reason: 'agent_echo', chatId, messageId: msg.key.id })); } catch {}
emitDebugEvent({
stage: 'ignored',
reason: 'agent_echo',
chatId: redactWhatsAppId(chatId),
messageId: msg.key.id,
});
}
continue;
}
// Skip empty messages
if (!event.body && !event.hasMedia) {
if (WHATSAPP_DEBUG) {
try {
console.log(JSON.stringify({ event: 'ignored', reason: 'empty', chatId, messageKeys: Object.keys(msg.message || {}) }));
} catch (err) {
console.error('Failed to log empty message event:', err);
}
}
emitDebugEvent({
stage: 'ignored',
reason: 'empty',
chatId: redactWhatsAppId(chatId),
messageKeys: Object.keys(msg.message || {}),
});
continue;
}
messageStore.remember(msg);
messageQueue.push(event);
emitDebugEvent({
stage: 'queued',
chatId: redactWhatsAppId(chatId),
senderId: redactWhatsAppId(senderId),
fromOwner: !!fromOwner,
bodyLength: event.body.length,
hasMedia: event.hasMedia,
mediaType: event.mediaType,
queueLength: messageQueue.length,
});
if (messageQueue.length > MAX_QUEUE_SIZE) {
messageQueue.shift();
}
@ -999,10 +1080,20 @@ app.get('/health', (req, res) => {
// Start
if (PAIR_ONLY) {
// Pair-only mode: just connect, show QR, save creds, exit. No HTTP server.
console.log('📱 WhatsApp pairing mode');
console.log(`📁 Session: ${SESSION_DIR}`);
console.log();
startSocket();
if (PAIR_JSON) {
emitPairEvent({ event: 'started', session: SESSION_DIR });
} else {
console.log('📱 WhatsApp pairing mode');
console.log(`📁 Session: ${SESSION_DIR}`);
console.log();
}
startSocket().catch((err) => {
emitPairEvent({ event: 'error', error: err?.message || String(err) });
if (!PAIR_JSON) {
console.error(err);
}
process.exit(1);
});
} else {
app.listen(PORT, '127.0.0.1', () => {
console.log(`🌉 WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`);
@ -1011,6 +1102,8 @@ if (PAIR_ONLY) {
console.log(`🔒 Allowed users: ${Array.from(ALLOWED_USERS).join(', ')}`);
} else if (WHATSAPP_MODE === 'self-chat') {
console.log(`🔒 Self-chat mode — only your own messages to yourself are processed.`);
} else if (WHATSAPP_MODE === 'bot' && WHATSAPP_DM_POLICY === 'pairing') {
console.log(`🤝 WHATSAPP_DM_POLICY=pairing — unknown DMs are forwarded for gateway pairing.`);
} else {
console.log(`🔒 No WHATSAPP_ALLOWED_USERS set — incoming messages are rejected.`);
console.log(` Set WHATSAPP_ALLOWED_USERS=<phone> to authorize specific users,`);