| #!/usr/bin/env node |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage } from '@whiskeysockets/baileys'; |
| import express from 'express'; |
| import { Boom } from '@hapi/boom'; |
| import pino from 'pino'; |
| import path from 'path'; |
| import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs'; |
| import { randomBytes } from 'crypto'; |
| import qrcode from 'qrcode-terminal'; |
|
|
| |
| 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 WHATSAPP_DEBUG = |
| typeof process !== 'undefined' && |
| process.env && |
| typeof process.env.WHATSAPP_DEBUG === 'string' && |
| ['1', 'true', 'yes', 'on'].includes(process.env.WHATSAPP_DEBUG.toLowerCase()); |
|
|
| const PORT = parseInt(getArg('port', '3000'), 10); |
| const SESSION_DIR = getArg('session', path.join(process.env.HOME || '~', '.hermes', 'whatsapp', 'session')); |
| const IMAGE_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'image_cache'); |
| const PAIR_ONLY = args.includes('--pair-only'); |
| const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); |
| const ALLOWED_USERS = (process.env.WHATSAPP_ALLOWED_USERS || '').split(',').map(s => s.trim()).filter(Boolean); |
| const DEFAULT_REPLY_PREFIX = 'β *Hermes Agent*\nββββββββββββ\n'; |
| const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined |
| ? DEFAULT_REPLY_PREFIX |
| : process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n'); |
|
|
| function formatOutgoingMessage(message) { |
| return REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : message; |
| } |
|
|
| mkdirSync(SESSION_DIR, { recursive: true }); |
|
|
| |
| function buildLidMap() { |
| const map = {}; |
| try { |
| for (const f of readdirSync(SESSION_DIR)) { |
| const m = f.match(/^lid-mapping-(\d+)\.json$/); |
| if (!m) continue; |
| const phone = m[1]; |
| const lid = JSON.parse(readFileSync(path.join(SESSION_DIR, f), 'utf8')); |
| if (lid) map[String(lid)] = phone; |
| } |
| } catch {} |
| return map; |
| } |
| let lidToPhone = buildLidMap(); |
|
|
| const logger = pino({ level: 'warn' }); |
|
|
| |
| const messageQueue = []; |
| const MAX_QUEUE_SIZE = 100; |
|
|
| |
| const recentlySentIds = new Set(); |
| const MAX_RECENT_IDS = 50; |
|
|
| 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, |
| |
| |
| getMessage: async (key) => { |
| |
| |
| return { conversation: '' }; |
| }, |
| }); |
|
|
| sock.ev.on('creds.update', () => { saveCreds(); lidToPhone = buildLidMap(); }); |
|
|
| 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 { |
| |
| 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.'); |
| |
| setTimeout(() => process.exit(0), 2000); |
| } |
| } |
| }); |
|
|
| sock.ev.on('messages.upsert', async ({ messages, type }) => { |
| |
| |
| if (type !== 'notify' && type !== 'append') return; |
|
|
| for (const msg of messages) { |
| 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(/@.*/, ''); |
|
|
| |
| if (msg.key.fromMe) { |
| if (isGroup || chatId.includes('status')) continue; |
|
|
| if (WHATSAPP_MODE === 'bot') { |
| |
| continue; |
| } |
|
|
| |
| |
| |
| |
| const myNumber = (sock.user?.id || '').replace(/:.*@/, '@').replace(/@.*/, ''); |
| const myLid = (sock.user?.lid || '').replace(/:.*@/, '@').replace(/@.*/, ''); |
| const chatNumber = chatId.replace(/@.*/, ''); |
| const isSelfChat = (myNumber && chatNumber === myNumber) || (myLid && chatNumber === myLid); |
| if (!isSelfChat) continue; |
| } |
|
|
| |
| if (!msg.key.fromMe && ALLOWED_USERS.length > 0) { |
| const resolvedNumber = lidToPhone[senderNumber] || senderNumber; |
| if (!ALLOWED_USERS.includes(resolvedNumber)) continue; |
| } |
|
|
| |
| 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'; |
| try { |
| const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage }); |
| const mime = msg.message.imageMessage.mimetype || 'image/jpeg'; |
| const extMap = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' }; |
| const ext = extMap[mime] || '.jpg'; |
| mkdirSync(IMAGE_CACHE_DIR, { recursive: true }); |
| const filePath = path.join(IMAGE_CACHE_DIR, `img_${randomBytes(6).toString('hex')}${ext}`); |
| writeFileSync(filePath, buf); |
| mediaUrls.push(filePath); |
| } catch (err) { |
| console.error('[bridge] Failed to download image:', err.message); |
| } |
| } 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'; |
| } |
|
|
| |
| if (hasMedia && !body) { |
| body = `[${mediaType} received]`; |
| } |
|
|
| |
| if (msg.key.fromMe && ((REPLY_PREFIX && 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 {} |
| } |
| continue; |
| } |
|
|
| |
| if (!body && !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); |
| } |
| } |
| 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(); |
| } |
| } |
| }); |
| } |
|
|
| |
| const app = express(); |
| app.use(express.json()); |
|
|
| |
| app.get('/messages', (req, res) => { |
| const msgs = messageQueue.splice(0, messageQueue.length); |
| res.json(msgs); |
| }); |
|
|
| |
| 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 { |
| const sent = await sock.sendMessage(chatId, { text: formatOutgoingMessage(message) }); |
|
|
| |
| if (sent?.key?.id) { |
| recentlySentIds.add(sent.key.id); |
| if (recentlySentIds.size > MAX_RECENT_IDS) { |
| recentlySentIds.delete(recentlySentIds.values().next().value); |
| } |
| } |
|
|
| res.json({ success: true, messageId: sent?.key?.id }); |
| } catch (err) { |
| res.status(500).json({ error: err.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 key = { id: messageId, fromMe: true, remoteJid: chatId }; |
| await sock.sendMessage(chatId, { text: formatOutgoingMessage(message), edit: key }); |
| res.json({ success: true }); |
| } catch (err) { |
| res.status(500).json({ error: err.message }); |
| } |
| }); |
|
|
| |
| 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'; |
| } |
|
|
| |
| 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); |
|
|
| |
| if (sent?.key?.id) { |
| recentlySentIds.add(sent.key.id); |
| if (recentlySentIds.size > MAX_RECENT_IDS) { |
| recentlySentIds.delete(recentlySentIds.values().next().value); |
| } |
| } |
|
|
| res.json({ success: true, messageId: sent?.key?.id }); |
| } catch (err) { |
| res.status(500).json({ error: err.message }); |
| } |
| }); |
|
|
| |
| 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 }); |
| } |
| }); |
|
|
| |
| 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 { |
| |
| } |
| } |
|
|
| res.json({ |
| name: chatId.replace(/@.*/, ''), |
| isGroup, |
| participants: [], |
| }); |
| }); |
|
|
| |
| app.get('/health', (req, res) => { |
| res.json({ |
| status: connectionState, |
| queueLength: messageQueue.length, |
| uptime: process.uptime(), |
| }); |
| }); |
|
|
| |
| if (PAIR_ONLY) { |
| |
| console.log('π± WhatsApp pairing mode'); |
| console.log(`π Session: ${SESSION_DIR}`); |
| console.log(); |
| startSocket(); |
| } else { |
| app.listen(PORT, '127.0.0.1', () => { |
| 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(); |
| }); |
| } |
|
|