diff --git a/.gitignore b/.gitignore index af9d9e750975..fd57664e62ae 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ source-data/* run_datagen_megascience_glm4-6.sh data/* node_modules/ +package-lock.json browser-use/ agent-browser/ # Private keys diff --git a/docs/messaging.md b/docs/messaging.md index d45509d08fc7..e89260436891 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -134,29 +134,62 @@ pip install discord.py>=2.0 ### WhatsApp -WhatsApp integration is more complex due to the lack of a simple bot API. +WhatsApp integration uses a Node.js bridge process that connects to WhatsApp Web +via [Baileys](https://github.com/WhiskeySockets/Baileys) (no browser/Chromium needed). -**Options:** -1. **WhatsApp Business API** (requires Meta verification) -2. **whatsapp-web.js** via Node.js bridge (for personal accounts) +**Requirements:** +- Node.js >= 18 **Bridge Setup:** -1. Install Node.js -2. Set up the bridge script (see `scripts/whatsapp-bridge/` for reference) -3. Configure in gateway: - ```json - { - "platforms": { - "whatsapp": { - "enabled": true, - "extra": { - "bridge_script": "/path/to/bridge.js", - "bridge_port": 3000 - } - } - } - } - ``` + +```bash +# 1. Install bridge dependencies +cd scripts/whatsapp-bridge +npm install + +# 2. First run - scan the QR code printed in terminal with your phone +node bridge.js --port 3000 + +# 3. After scanning, the session is saved automatically. +# Subsequent launches will reconnect without a QR code. +``` + +**Gateway Configuration:** + +Add to `~/.hermes/gateway.json`: + +```json +{ + "platforms": { + "whatsapp": { + "enabled": true, + "extra": { + "bridge_script": "/absolute/path/to/scripts/whatsapp-bridge/bridge.js", + "bridge_port": 3000 + } + } + } +} +``` + +Or set environment variables in `~/.hermes/.env`: + +```bash +WHATSAPP_ENABLED=true +``` + +**How it works:** + +The gateway launches the bridge as a subprocess (`node bridge.js --port 3000 --session `). +The bridge connects to WhatsApp Web, buffers incoming messages, and exposes HTTP endpoints +that the Python adapter polls. Media files (images, voice notes, documents) are downloaded +and served via a local HTTP endpoint. + +**Notes:** +- The first time you run the bridge, scan the QR code with WhatsApp on your phone +- Session data is stored in `~/.hermes/whatsapp/session/` by default +- If you get logged out, delete the session directory and re-authenticate +- The bridge auto-reconnects on temporary disconnections ## Configuration diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index a90f94e39fa6..4782e57ab383 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -115,8 +115,9 @@ async def connect(self) -> bool: try: # Ensure session directory exists self._session_path.mkdir(parents=True, exist_ok=True) - - # Start the bridge process + + # Start the bridge process with inherited stdout/stderr so the + # QR code printed by the bridge is visible to the user. self._bridge_process = subprocess.Popen( [ "node", @@ -124,29 +125,40 @@ async def connect(self) -> bool: "--port", str(self._bridge_port), "--session", str(self._session_path), ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, ) - - # Wait for bridge to be ready (look for ready signal) - # This is a simplified version - real implementation would - # wait for an HTTP health check or specific stdout message - await asyncio.sleep(5) - - if self._bridge_process.poll() is not None: - stderr = self._bridge_process.stderr.read() if self._bridge_process.stderr else "" - print(f"[{self.name}] Bridge process died: {stderr}") + + # Poll the bridge health endpoint until it responds (up to 30s). + import aiohttp + ready = False + for _ in range(30): + await asyncio.sleep(1) + if self._bridge_process.poll() is not None: + print(f"[{self.name}] Bridge process exited unexpectedly.") + return False + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"http://localhost:{self._bridge_port}/health", + timeout=aiohttp.ClientTimeout(total=2) + ) as resp: + if resp.status == 200: + ready = True + break + except Exception: + pass + + if not ready: + print(f"[{self.name}] Bridge did not become ready within 30s.") return False - + # Start message polling task asyncio.create_task(self._poll_messages()) - + self._running = True print(f"[{self.name}] Bridge started on port {self._bridge_port}") print(f"[{self.name}] Scan QR code if prompted (check bridge output)") return True - + except Exception as e: print(f"[{self.name}] Failed to start bridge: {e}") return False diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js new file mode 100644 index 000000000000..55e5af930748 --- /dev/null +++ b/scripts/whatsapp-bridge/bridge.js @@ -0,0 +1,405 @@ +#!/usr/bin/env node +/** + * WhatsApp Web bridge for Hermes Agent. + * + * Connects to WhatsApp via Baileys (no browser/Chromium needed) and exposes + * HTTP endpoints consumed by the Python WhatsApp adapter + * (gateway/platforms/whatsapp.py). + * + * Usage: + * node bridge.js --port 3000 --session /path/to/session + * + * Endpoints: + * GET /messages Poll for new incoming messages (returns & clears queue) + * POST /send Send a message { chatId, message, replyTo? } + * POST /typing Send typing indicator { chatId } + * GET /chat/:chatId Get chat info { name, isGroup, participants } + * GET /health Health check + * GET /media/:file Serve downloaded media files + */ + +import { + makeWASocket, + useMultiFileAuthState, + DisconnectReason, + downloadMediaMessage, + fetchLatestBaileysVersion, +} from "@whiskeysockets/baileys"; +import express from "express"; +import fs from "fs"; +import path from "path"; +import pino from "pino"; +import qrcode from "qrcode-terminal"; + +// --------------------------------------------------------------------------- +// CLI argument parsing +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); + +function getArg(name, defaultValue) { + const idx = args.indexOf(`--${name}`); + return idx !== -1 && args[idx + 1] ? args[idx + 1] : defaultValue; +} + +const PORT = parseInt(getArg("port", "3000"), 10); +const SESSION_PATH = getArg( + "session", + path.join(process.env.HOME || "~", ".hermes", "whatsapp", "session") +); +const MEDIA_DIR = path.join(SESSION_PATH, "media"); + +fs.mkdirSync(SESSION_PATH, { recursive: true }); +fs.mkdirSync(MEDIA_DIR, { recursive: true }); + +const logger = pino({ level: "warn" }); + +// --------------------------------------------------------------------------- +// Message queue (buffered for polling by the Python adapter) +// --------------------------------------------------------------------------- + +const messageQueue = []; + +// Recent messages stored for reply-quoting (limited to last 200) +const recentMessages = new Map(); +const RECENT_MSG_LIMIT = 200; + +function storeRecentMessage(msg) { + if (recentMessages.size >= RECENT_MSG_LIMIT) { + const oldest = recentMessages.keys().next().value; + recentMessages.delete(oldest); + } + recentMessages.set(msg.key.id, msg); +} + +// --------------------------------------------------------------------------- +// Group name cache +// --------------------------------------------------------------------------- + +const groupNameCache = {}; + +async function getGroupName(sock, groupJid) { + if (groupNameCache[groupJid]) return groupNameCache[groupJid]; + try { + const meta = await sock.groupMetadata(groupJid); + groupNameCache[groupJid] = meta.subject; + return meta.subject; + } catch { + return groupJid; + } +} + +// --------------------------------------------------------------------------- +// Media helpers +// --------------------------------------------------------------------------- + +const MIME_EXT = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", + "video/mp4": ".mp4", + "video/3gpp": ".3gp", + "audio/ogg; codecs=opus": ".ogg", + "audio/ogg": ".ogg", + "audio/mp4": ".m4a", + "audio/mpeg": ".mp3", + "application/pdf": ".pdf", +}; + +const TYPE_EXT = { + image: ".jpg", + video: ".mp4", + audio: ".ogg", + ptt: ".ogg", + document: ".bin", + sticker: ".webp", +}; + +function getMediaExtension(mediaType, mimetype) { + if (mimetype && MIME_EXT[mimetype]) return MIME_EXT[mimetype]; + return TYPE_EXT[mediaType] || ".bin"; +} + +function detectMediaType(msg) { + if (msg.message?.imageMessage) return "image"; + if (msg.message?.videoMessage) return "video"; + if (msg.message?.stickerMessage) return "sticker"; + if (msg.message?.audioMessage) { + return msg.message.audioMessage.ptt ? "ptt" : "audio"; + } + if (msg.message?.documentMessage) return "document"; + if (msg.message?.documentWithCaptionMessage) return "document"; + return null; +} + +function getMediaMessage(msg) { + return ( + msg.message?.imageMessage || + msg.message?.videoMessage || + msg.message?.audioMessage || + msg.message?.stickerMessage || + msg.message?.documentMessage || + msg.message?.documentWithCaptionMessage?.message?.documentMessage || + null + ); +} + +/** + * Strip the WhatsApp JID suffix to get a human-readable name. + * "xxxx@s.whatsapp.net" -> "xxxx" + */ +function jidToName(jid) { + return jid.replace("@s.whatsapp.net", "").replace("@g.us", ""); +} + +// --------------------------------------------------------------------------- +// WhatsApp connection via Baileys +// --------------------------------------------------------------------------- + +let sock = null; +let connectionState = "disconnected"; + +async function connectWhatsApp() { + const { state, saveCreds } = await useMultiFileAuthState(SESSION_PATH); + const { version } = await fetchLatestBaileysVersion(); + + sock = makeWASocket({ + version, + auth: state, + logger, + markOnlineOnConnect: true, + }); + + // Persist session credentials + sock.ev.on("creds.update", saveCreds); + + // Connection status + sock.ev.on("connection.update", ({ connection, lastDisconnect, qr }) => { + if (qr) { + console.log("\n[bridge] Scan this QR code with WhatsApp:\n"); + qrcode.generate(qr, { small: true }); + console.log(""); + } + + if (connection === "open") { + connectionState = "connected"; + console.log( + `[bridge] Connected to WhatsApp. Bridge ready on port ${PORT}.` + ); + } + + if (connection === "close") { + connectionState = "disconnected"; + const statusCode = lastDisconnect?.error?.output?.statusCode; + + if (statusCode === DisconnectReason.loggedOut) { + console.error( + "[bridge] Logged out. Delete the session directory and re-authenticate." + ); + process.exit(1); + } + + console.log("[bridge] Disconnected. Reconnecting in 3s..."); + setTimeout(connectWhatsApp, 3000); + } + }); + + // Incoming messages + sock.ev.on("messages.upsert", async ({ messages, type }) => { + if (type !== "notify") return; + + for (const msg of messages) { + // Skip our own messages + if (msg.key.fromMe) continue; + // Skip status/broadcast updates + if (msg.key.remoteJid === "status@broadcast") continue; + + storeRecentMessage(msg); + + const chatId = msg.key.remoteJid; + const isGroup = chatId.endsWith("@g.us"); + const senderId = isGroup ? msg.key.participant : chatId; + const senderName = + msg.pushName || jidToName(senderId || chatId) || "Unknown"; + const chatName = isGroup + ? await getGroupName(sock, chatId) + : senderName; + + // Extract text body from various message types + const body = + msg.message?.conversation || + msg.message?.extendedTextMessage?.text || + msg.message?.imageMessage?.caption || + msg.message?.videoMessage?.caption || + msg.message?.documentMessage?.caption || + msg.message?.documentWithCaptionMessage?.message?.documentMessage + ?.caption || + ""; + + const event = { + messageId: msg.key.id, + chatId, + chatName, + senderId, + senderName, + isGroup, + body, + hasMedia: false, + mediaType: "", + mediaUrls: [], + timestamp: + (msg.messageTimestamp || Math.floor(Date.now() / 1000)) * 1000, + }; + + // Handle media messages + const mediaType = detectMediaType(msg); + const mediaMsg = getMediaMessage(msg); + + if (mediaType && mediaMsg) { + event.hasMedia = true; + event.mediaType = mediaType; + + try { + const buffer = await downloadMediaMessage( + msg, + "buffer", + {}, + { logger, reuploadRequest: sock.updateMediaMessage } + ); + const ext = getMediaExtension(mediaType, mediaMsg.mimetype); + const filename = `${msg.key.id}${ext}`; + const filepath = path.join(MEDIA_DIR, filename); + fs.writeFileSync(filepath, buffer); + event.mediaUrls = [`http://localhost:${PORT}/media/${filename}`]; + } catch (dlErr) { + console.error("[bridge] Media download failed:", dlErr.message); + } + } + + messageQueue.push(event); + } + }); +} + +// --------------------------------------------------------------------------- +// Express HTTP server +// --------------------------------------------------------------------------- + +const app = express(); +app.use(express.json()); +app.use("/media", express.static(MEDIA_DIR)); + +// Health check +app.get("/health", (_req, res) => { + res.json({ status: connectionState }); +}); + +// Poll messages - returns queued messages and clears the queue +app.get("/messages", (_req, res) => { + const messages = messageQueue.splice(0); + res.json(messages); +}); + +// Send message +app.post("/send", async (req, res) => { + if (!sock || connectionState !== "connected") { + return res.status(503).json({ error: "Not connected to WhatsApp" }); + } + + try { + const { chatId, message, replyTo } = req.body; + if (!chatId || !message) { + return res + .status(400) + .json({ error: "chatId and message are required" }); + } + + const opts = {}; + if (replyTo) { + const original = recentMessages.get(replyTo); + if (original) { + opts.quoted = original; + } + } + + const sent = await sock.sendMessage(chatId, { text: message }, opts); + res.json({ messageId: sent.key.id }); + } catch (err) { + console.error("[bridge] Send error:", err.message); + res.status(500).json({ error: err.message }); + } +}); + +// Typing indicator +app.post("/typing", async (req, res) => { + try { + const { chatId } = req.body; + if (chatId && sock && connectionState === "connected") { + await sock.presenceSubscribe(chatId); + await sock.sendPresenceUpdate("composing", chatId); + } + res.json({ ok: true }); + } catch { + // Typing failures are non-critical + res.json({ ok: true }); + } +}); + +// Chat info +app.get("/chat/:chatId", async (req, res) => { + const chatId = req.params.chatId; + const isGroup = chatId.endsWith("@g.us"); + + if (!sock || connectionState !== "connected") { + return res.json({ name: jidToName(chatId), isGroup, participants: [] }); + } + + try { + if (isGroup) { + const metadata = await sock.groupMetadata(chatId); + res.json({ + name: metadata.subject, + isGroup: true, + participants: metadata.participants.map((p) => ({ + id: p.id, + admin: p.admin || null, + })), + }); + } else { + res.json({ name: jidToName(chatId), isGroup: false, participants: [] }); + } + } catch { + res.json({ name: jidToName(chatId), isGroup, participants: [] }); + } +}); + +// --------------------------------------------------------------------------- +// Graceful shutdown +// --------------------------------------------------------------------------- + +function shutdown() { + console.log("[bridge] Shutting down..."); + if (sock) { + sock.end(undefined); + } + process.exit(0); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); + +// --------------------------------------------------------------------------- +// Start +// --------------------------------------------------------------------------- + +app.listen(PORT, () => { + console.log(`[bridge] HTTP server listening on port ${PORT}`); + console.log(`[bridge] Session path: ${SESSION_PATH}`); + console.log(`[bridge] Media directory: ${MEDIA_DIR}`); + + connectWhatsApp().catch((err) => { + console.error("[bridge] Fatal error:", err); + process.exit(1); + }); +}); diff --git a/scripts/whatsapp-bridge/package.json b/scripts/whatsapp-bridge/package.json new file mode 100644 index 000000000000..6d3a73112106 --- /dev/null +++ b/scripts/whatsapp-bridge/package.json @@ -0,0 +1,16 @@ +{ + "name": "hermes-whatsapp-bridge", + "version": "1.0.0", + "type": "module", + "description": "WhatsApp Web bridge for Hermes Agent gateway", + "main": "bridge.js", + "scripts": { + "start": "node bridge.js" + }, + "dependencies": { + "@whiskeysockets/baileys": "^6.7.16", + "express": "^4.21.0", + "pino": "^9.6.0", + "qrcode-terminal": "^0.12.0" + } +}