#!/usr/bin/env node /** * Hermes Agent WhatsApp Bridge * * Standalone Node.js process that connects to WhatsApp via Baileys * and exposes HTTP endpoints for the Python gateway adapter. * * Endpoints (matches gateway/platforms/whatsapp.py expectations): * GET /messages - Long-poll for new incoming messages * POST /send - Send a message { chatId, message, replyTo? } * POST /edit - Edit a sent message { chatId, messageId, message } * POST /send-media - Send media natively { chatId, filePath, mediaType?, caption?, fileName? } * POST /typing - Send typing indicator { chatId } * GET /chat/:id - Get chat info * GET /health - Health check * * Usage: * node bridge.js --port 3000 --session ~/.hermes/whatsapp/session */ import { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion } from '@whiskeysockets/baileys'; import express from 'express'; import { Boom } from '@hapi/boom'; import pino from 'pino'; import path from 'path'; import { mkdirSync, readFileSync, existsSync } from 'fs'; import qrcode from 'qrcode-terminal'; // Parse CLI args const args = process.argv.slice(2); function getArg(name, defaultVal) { const idx = args.indexOf(`--${name}`); return idx !== -1 && args[idx + 1] ? args[idx + 1] : defaultVal; } const PORT = parseInt(getArg('port', '3000'), 10); const SESSION_DIR = getArg('session', path.join(process.env.HOME || '~', '.hermes', 'whatsapp', 'session')); const PAIR_ONLY = args.includes('--pair-only'); const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat" const ALLOWED_USERS = (process.env.WHATSAPP_ALLOWED_USERS || '').split(',').map(s => s.trim()).filter(Boolean); mkdirSync(SESSION_DIR, { recursive: true }); const logger = pino({ level: 'warn' }); // Message queue for polling const messageQueue = []; const MAX_QUEUE_SIZE = 100; let sock = null; let connectionState = 'disconnected'; async function startSocket() { const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR); const { version } = await fetchLatestBaileysVersion(); sock = makeWASocket({ version, auth: state, logger, printQRInTerminal: false, browser: ['Hermes Agent', 'Chrome', '120.0'], syncFullHistory: false, markOnlineOnConnect: false, }); sock.ev.on('creds.update', saveCreds); sock.ev.on('connection.update', (update) => { 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 (connection === 'close') { const reason = new Boom(lastDisconnect?.error)?.output?.statusCode; connectionState = 'disconnected'; if (reason === DisconnectReason.loggedOut) { 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...`); } setTimeout(startSocket, reason === 515 ? 1000 : 3000); } } else if (connection === 'open') { connectionState = 'connected'; console.log('āœ… WhatsApp connected!'); if (PAIR_ONLY) { console.log('āœ… Pairing complete. Credentials saved.'); // Give Baileys a moment to flush creds, then exit cleanly setTimeout(() => process.exit(0), 2000); } } }); sock.ev.on('messages.upsert', ({ messages, type }) => { if (type !== 'notify') return; for (const msg of messages) { if (!msg.message) continue; const chatId = msg.key.remoteJid; const senderId = msg.key.participant || chatId; const isGroup = chatId.endsWith('@g.us'); const senderNumber = senderId.replace(/@.*/, ''); // Handle fromMe messages based on mode if (msg.key.fromMe) { if (isGroup || chatId.includes('status')) continue; if (WHATSAPP_MODE === 'bot') { // Bot mode: separate number. ALL fromMe are echo-backs of our own replies — skip. continue; } // Self-chat mode: only allow messages in the user's own self-chat const myNumber = (sock.user?.id || '').replace(/:.*@/, '@').replace(/@.*/, ''); const chatNumber = chatId.replace(/@.*/, ''); const isSelfChat = myNumber && chatNumber === myNumber; if (!isSelfChat) continue; } // Check allowlist for messages from others if (!msg.key.fromMe && ALLOWED_USERS.length > 0 && !ALLOWED_USERS.includes(senderNumber)) { continue; } // Extract message body let body = ''; let hasMedia = false; let mediaType = ''; const mediaUrls = []; if (msg.message.conversation) { body = msg.message.conversation; } else if (msg.message.extendedTextMessage?.text) { body = msg.message.extendedTextMessage.text; } else if (msg.message.imageMessage) { body = msg.message.imageMessage.caption || ''; hasMedia = true; mediaType = 'image'; } else if (msg.message.videoMessage) { body = msg.message.videoMessage.caption || ''; hasMedia = true; mediaType = 'video'; } else if (msg.message.audioMessage || msg.message.pttMessage) { hasMedia = true; mediaType = msg.message.pttMessage ? 'ptt' : 'audio'; } else if (msg.message.documentMessage) { body = msg.message.documentMessage.caption || msg.message.documentMessage.fileName || ''; hasMedia = true; mediaType = 'document'; } // Skip empty messages if (!body && !hasMedia) continue; const event = { messageId: msg.key.id, chatId, senderId, senderName: msg.pushName || senderNumber, chatName: isGroup ? (chatId.split('@')[0]) : (msg.pushName || senderNumber), isGroup, body, hasMedia, mediaType, mediaUrls, timestamp: msg.messageTimestamp, }; messageQueue.push(event); if (messageQueue.length > MAX_QUEUE_SIZE) { messageQueue.shift(); } } }); } // HTTP server const app = express(); app.use(express.json()); // Poll for new messages (long-poll style) app.get('/messages', (req, res) => { const msgs = messageQueue.splice(0, messageQueue.length); res.json(msgs); }); // Send a message app.post('/send', async (req, res) => { if (!sock || connectionState !== 'connected') { return res.status(503).json({ error: 'Not connected to WhatsApp' }); } const { chatId, message, replyTo } = req.body; if (!chatId || !message) { return res.status(400).json({ error: 'chatId and message are required' }); } try { // Prefix responses so the user can distinguish agent replies from their // own messages (especially in self-chat / "Message Yourself"). const prefixed = `āš• *Hermes Agent*\n────────────\n${message}`; const sent = await sock.sendMessage(chatId, { text: prefixed }); res.json({ success: true, messageId: sent?.key?.id }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Edit a previously sent message app.post('/edit', async (req, res) => { if (!sock || connectionState !== 'connected') { return res.status(503).json({ error: 'Not connected to WhatsApp' }); } const { chatId, messageId, message } = req.body; if (!chatId || !messageId || !message) { return res.status(400).json({ error: 'chatId, messageId, and message are required' }); } try { const prefixed = `āš• *Hermes Agent*\n────────────\n${message}`; const key = { id: messageId, fromMe: true, remoteJid: chatId }; await sock.sendMessage(chatId, { text: prefixed, edit: key }); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // MIME type map and media type inference for /send-media const MIME_MAP = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo', mkv: 'video/x-matroska', '3gp': 'video/3gpp', pdf: 'application/pdf', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }; function inferMediaType(ext) { if (['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(ext)) return 'image'; if (['mp4', 'mov', 'avi', 'mkv', '3gp'].includes(ext)) return 'video'; if (['ogg', 'opus', 'mp3', 'wav', 'm4a'].includes(ext)) return 'audio'; return 'document'; } // Send media (image, video, document) natively app.post('/send-media', async (req, res) => { if (!sock || connectionState !== 'connected') { return res.status(503).json({ error: 'Not connected to WhatsApp' }); } const { chatId, filePath, mediaType, caption, fileName } = req.body; if (!chatId || !filePath) { return res.status(400).json({ error: 'chatId and filePath are required' }); } try { if (!existsSync(filePath)) { return res.status(404).json({ error: `File not found: ${filePath}` }); } const buffer = readFileSync(filePath); const ext = filePath.toLowerCase().split('.').pop(); const type = mediaType || inferMediaType(ext); let msgPayload; switch (type) { case 'image': msgPayload = { image: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'image/jpeg' }; break; case 'video': msgPayload = { video: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'video/mp4' }; break; case 'audio': { const audioMime = (ext === 'ogg' || ext === 'opus') ? 'audio/ogg; codecs=opus' : 'audio/mpeg'; msgPayload = { audio: buffer, mimetype: audioMime, ptt: ext === 'ogg' || ext === 'opus' }; break; } case 'document': default: msgPayload = { document: buffer, fileName: fileName || path.basename(filePath), caption: caption || undefined, mimetype: MIME_MAP[ext] || 'application/octet-stream', }; break; } const sent = await sock.sendMessage(chatId, msgPayload); res.json({ success: true, messageId: sent?.key?.id }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Typing indicator app.post('/typing', async (req, res) => { if (!sock || connectionState !== 'connected') { return res.status(503).json({ error: 'Not connected' }); } const { chatId } = req.body; if (!chatId) return res.status(400).json({ error: 'chatId required' }); try { await sock.sendPresenceUpdate('composing', chatId); res.json({ success: true }); } catch (err) { res.json({ success: false }); } }); // Chat info app.get('/chat/:id', async (req, res) => { const chatId = req.params.id; const isGroup = chatId.endsWith('@g.us'); if (isGroup && sock) { try { const metadata = await sock.groupMetadata(chatId); return res.json({ name: metadata.subject, isGroup: true, participants: metadata.participants.map(p => p.id), }); } catch { // Fall through to default } } res.json({ name: chatId.replace(/@.*/, ''), isGroup, participants: [], }); }); // Health check app.get('/health', (req, res) => { res.json({ status: connectionState, queueLength: messageQueue.length, uptime: process.uptime(), }); }); // 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(); } else { app.listen(PORT, () => { console.log(`šŸŒ‰ WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`); console.log(`šŸ“ Session stored in: ${SESSION_DIR}`); if (ALLOWED_USERS.length > 0) { console.log(`šŸ”’ Allowed users: ${ALLOWED_USERS.join(', ')}`); } else { console.log(`āš ļø No WHATSAPP_ALLOWED_USERS set — all messages will be processed`); } console.log(); startSocket(); }); }