main
js 615 lines 20.4 KB
Raw
1 #!/usr/bin/env node
2 /**
3 * Agent Zero WhatsApp Bridge
4 *
5 * Standalone Node.js process that connects to WhatsApp via Baileys
6 * and exposes HTTP endpoints for the Python plugin.
7 *
8 * Endpoints:
9 * GET /messages - Poll for new incoming messages
10 * POST /send - Send a message { chatId, message, replyTo? }
11 * POST /edit - Edit a sent message { chatId, messageId, message }
12 * POST /send-media - Send media { chatId, filePath, mediaType?, caption?, fileName? }
13 * POST /typing - Send typing indicator { chatId }
14 * GET /chat/:id - Get chat info
15 * GET /health - Health check
16 *
17 * Usage:
18 * node bridge.js --port 3100 --session /path/to/session --cache-dir /path/to/media
19 */
20
21 import { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage } from '@whiskeysockets/baileys';
22 import express from 'express';
23 import { Boom } from '@hapi/boom';
24 import pino from 'pino';
25 import path from 'path';
26 import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
27 import { randomBytes } from 'crypto';
28 import qrcode from 'qrcode-terminal';
29 import QRCode from 'qrcode';
30
31 // Parse CLI args
32 const args = process.argv.slice(2);
33 function getArg(name, defaultVal) {
34 const idx = args.indexOf(`--${name}`);
35 return idx !== -1 && args[idx + 1] ? args[idx + 1] : defaultVal;
36 }
37
38 const WHATSAPP_DEBUG =
39 typeof process !== 'undefined' &&
40 process.env &&
41 typeof process.env.WHATSAPP_DEBUG === 'string' &&
42 ['1', 'true', 'yes', 'on'].includes(process.env.WHATSAPP_DEBUG.toLowerCase());
43
44 const DEFAULT_DATA_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..', 'tmp', 'whatsapp');
45 const PORT = parseInt(getArg('port', '3100'), 10);
46 const SESSION_DIR = getArg('session', path.join(DEFAULT_DATA_ROOT, 'session'));
47 const CACHE_DIR = getArg('cache-dir', path.join(DEFAULT_DATA_ROOT, 'media'));
48 const PAIR_ONLY = args.includes('--pair-only');
49 const MODE = getArg('mode', 'self-chat'); // "dedicated" or "self-chat"
50
51
52 mkdirSync(SESSION_DIR, { recursive: true });
53 mkdirSync(CACHE_DIR, { recursive: true });
54
55 // Build LID -> phone reverse map from session files (lid-mapping-{phone}.json)
56 function buildLidMap() {
57 const map = {};
58 try {
59 for (const f of readdirSync(SESSION_DIR)) {
60 const m = f.match(/^lid-mapping-(\d+)\.json$/);
61 if (!m) continue;
62 const phone = m[1];
63 const lid = JSON.parse(readFileSync(path.join(SESSION_DIR, f), 'utf8'));
64 if (lid) map[String(lid)] = phone;
65 }
66 } catch {}
67 return map;
68 }
69 let lidToPhone = buildLidMap();
70
71 // Cache group names to avoid repeated metadata fetches
72 const groupNameCache = {};
73
74 // Extract raw number from a JID (strips @domain and :device)
75 function numOf(jid) {
76 return (jid || '').split('@')[0].split(':')[0];
77 }
78
79 // Resolve LID-based number to phone number using lidToPhone map
80 function resolveNumber(num) {
81 const raw = num.split(':')[0];
82 return lidToPhone[raw] || lidToPhone[num] || raw;
83 }
84
85 const logger = pino({ level: 'warn' });
86
87 // Message queue for polling
88 const messageQueue = [];
89 const MAX_QUEUE_SIZE = 100;
90
91 // Track recently sent message IDs to prevent echo-back loops
92 const recentlySentIds = new Set();
93 const MAX_RECENT_IDS = 50;
94
95 // Store received messages for reply quoting
96 const messageStore = new Map();
97 const MAX_STORED_MESSAGES = 200;
98
99 let sock = null;
100 let connectionState = 'disconnected';
101 let latestQrDataUrl = null;
102
103 async function startSocket() {
104 const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
105 const { version } = await fetchLatestBaileysVersion();
106
107 sock = makeWASocket({
108 version,
109 auth: state,
110 logger,
111 printQRInTerminal: false,
112 browser: ['Agent Zero', 'Chrome', '120.0'],
113 syncFullHistory: false,
114 markOnlineOnConnect: false,
115 getMessage: async (key) => {
116 return { conversation: '' };
117 },
118 });
119
120 sock.ev.on('creds.update', () => { saveCreds(); lidToPhone = buildLidMap(); });
121
122 sock.ev.on('connection.update', (update) => {
123 const { connection, lastDisconnect, qr } = update;
124
125 if (qr) {
126 console.log('\n[bridge] Scan this QR code with WhatsApp on your phone:\n');
127 qrcode.generate(qr, { small: true });
128 console.log('\n[bridge] Waiting for scan...\n');
129 QRCode.toDataURL(qr, { width: 256, margin: 2 }).then(url => {
130 latestQrDataUrl = url;
131 }).catch(() => {});
132 }
133
134 if (connection === 'close') {
135 const reason = new Boom(lastDisconnect?.error)?.output?.statusCode;
136 connectionState = 'disconnected';
137
138 if (reason === DisconnectReason.loggedOut) {
139 console.log('[bridge] Logged out. Delete session and restart to re-authenticate.');
140 process.exit(1);
141 } else {
142 if (reason === 515) {
143 console.log('[bridge] WhatsApp requested restart (code 515). Reconnecting...');
144 } else {
145 console.log(`[bridge] Connection closed (reason: ${reason}). Reconnecting in 3s...`);
146 }
147 setTimeout(startSocket, reason === 515 ? 1000 : 3000);
148 }
149 } else if (connection === 'open') {
150 connectionState = 'connected';
151 latestQrDataUrl = null;
152 console.log('[bridge] WhatsApp connected');
153 if (PAIR_ONLY) {
154 console.log('[bridge] Pairing complete. Credentials saved.');
155 setTimeout(() => process.exit(0), 2000);
156 }
157 }
158 });
159
160 sock.ev.on('messages.upsert', async ({ messages, type }) => {
161 if (type !== 'notify' && type !== 'append') return;
162
163 for (const msg of messages) {
164 if (!msg.message) continue;
165
166 const chatId = msg.key.remoteJid;
167 if (WHATSAPP_DEBUG) {
168 try {
169 console.log(JSON.stringify({
170 event: 'upsert', type,
171 fromMe: !!msg.key.fromMe, chatId,
172 senderId: msg.key.participant || chatId,
173 messageKeys: Object.keys(msg.message || {}),
174 }));
175 } catch {}
176 }
177 const senderId = msg.key.participant || chatId;
178 const isGroup = chatId.endsWith('@g.us');
179 const senderNumber = senderId.replace(/@.*/, '');
180
181 // Handle fromMe messages based on mode
182 if (msg.key.fromMe) {
183 if (isGroup || chatId.includes('status')) continue;
184
185 if (MODE === 'dedicated') {
186 // Dedicated mode: separate number — all fromMe are echo-backs, skip
187 continue;
188 }
189
190 // Self-chat mode: only accept messages in the user's own self-chat
191 const myNumber = (sock.user?.id || '').replace(/:.*@/, '@').replace(/@.*/, '');
192 const myLid = (sock.user?.lid || '').replace(/:.*@/, '@').replace(/@.*/, '');
193 const chatNumber = chatId.replace(/@.*/, '');
194 const isSelfChat = (myNumber && chatNumber === myNumber) || (myLid && chatNumber === myLid);
195 if (!isSelfChat) continue;
196 }
197
198 // Skip status broadcasts
199 if (chatId === 'status@broadcast') continue;
200
201 // Unwrap documentWithCaptionMessage (Baileys wraps captioned docs)
202 if (msg.message.documentWithCaptionMessage?.message?.documentMessage) {
203 msg.message.documentMessage = msg.message.documentWithCaptionMessage.message.documentMessage;
204 }
205
206 // Extract message body
207 let body = '';
208 let hasMedia = false;
209 let mediaType = '';
210 const mediaUrls = [];
211
212 if (msg.message.conversation) {
213 body = msg.message.conversation;
214 } else if (msg.message.extendedTextMessage?.text) {
215 body = msg.message.extendedTextMessage.text;
216 } else if (msg.message.imageMessage) {
217 body = msg.message.imageMessage.caption || '';
218 hasMedia = true;
219 mediaType = 'image';
220 } else if (msg.message.videoMessage) {
221 body = msg.message.videoMessage.caption || '';
222 hasMedia = true;
223 mediaType = 'video';
224 } else if (msg.message.audioMessage || msg.message.pttMessage) {
225 hasMedia = true;
226 mediaType = msg.message.pttMessage ? 'ptt' : 'audio';
227 } else if (msg.message.documentMessage) {
228 body = msg.message.documentMessage.caption || '';
229 hasMedia = true;
230 mediaType = 'document';
231 }
232
233 // Download media to disk
234 if (hasMedia) {
235 try {
236 const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage });
237 let ext = '.bin';
238 let prefix = mediaType;
239 if (mediaType === 'image') {
240 const mime = msg.message.imageMessage?.mimetype || 'image/jpeg';
241 const extMap = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' };
242 ext = extMap[mime] || '.jpg';
243 } else if (mediaType === 'video') {
244 const mime = msg.message.videoMessage?.mimetype || 'video/mp4';
245 ext = mime.includes('mp4') ? '.mp4' : '.mkv';
246 } else if (mediaType === 'audio' || mediaType === 'ptt') {
247 const mime = msg.message.audioMessage?.mimetype || msg.message.pttMessage?.mimetype || 'audio/ogg';
248 ext = mime.includes('opus') || mime.includes('ogg') ? '.ogg' : '.mp3';
249 } else if (mediaType === 'document') {
250 const docMsg = msg.message.documentMessage;
251 const fileName = docMsg?.fileName || '';
252 if (fileName) {
253 // Use original filename for documents
254 const filePath = path.join(CACHE_DIR, `${randomBytes(4).toString('hex')}_${fileName}`);
255 writeFileSync(filePath, buf);
256 mediaUrls.push(filePath);
257 if (!body) body = fileName;
258 } else {
259 const mime = docMsg?.mimetype || 'application/octet-stream';
260 const docExtMap = { 'application/pdf': '.pdf', 'application/msword': '.doc' };
261 ext = docExtMap[mime] || '.bin';
262 }
263 }
264 // Write file if not already handled (document with fileName)
265 if (mediaUrls.length === 0) {
266 const filePath = path.join(CACHE_DIR, `${prefix}_${randomBytes(6).toString('hex')}${ext}`);
267 writeFileSync(filePath, buf);
268 mediaUrls.push(filePath);
269 }
270 } catch (err) {
271 console.error(`[bridge] Failed to download ${mediaType}:`, err.message);
272 }
273 }
274
275 // For media without caption, use a placeholder
276 if (hasMedia && !body) {
277 body = `[${mediaType} received]`;
278 }
279
280 // Skip echo-backs via recently sent IDs
281 if (recentlySentIds.has(msg.key.id)) {
282 if (WHATSAPP_DEBUG) {
283 try { console.log(JSON.stringify({ event: 'ignored', reason: 'agent_echo', chatId, messageId: msg.key.id })); } catch {}
284 }
285 continue;
286 }
287
288 // Skip empty messages
289 if (!body && !hasMedia) {
290 if (WHATSAPP_DEBUG) {
291 try {
292 console.log(JSON.stringify({ event: 'ignored', reason: 'empty', chatId, messageKeys: Object.keys(msg.message || {}) }));
293 } catch {}
294 }
295 continue;
296 }
297
298 // Detect if the bot was mentioned or replied to in a group message
299 let mentionedMe = false;
300 let repliedToMe = false;
301 if (isGroup && sock.user) {
302 const contextInfo = msg.message.extendedTextMessage?.contextInfo
303 || msg.message.imageMessage?.contextInfo
304 || msg.message.videoMessage?.contextInfo
305 || msg.message.documentMessage?.contextInfo
306 || null;
307
308 // Build set of bot's own numbers for comparison
309 const myNums = new Set();
310 if (sock.user.id) myNums.add(numOf(sock.user.id));
311 if (sock.user.lid) myNums.add(numOf(sock.user.lid));
312 for (const [lid, phone] of Object.entries(lidToPhone)) {
313 if (myNums.has(lid)) myNums.add(String(phone));
314 if (myNums.has(String(phone))) myNums.add(lid);
315 }
316
317 // Check @mentions
318 const mentionedJids = contextInfo?.mentionedJid || [];
319 for (const jid of mentionedJids) {
320 if (myNums.has(numOf(jid))) { mentionedMe = true; break; }
321 }
322
323 // Check if replying to a bot message
324 if (contextInfo?.stanzaId) {
325 const replyParticipant = contextInfo.participant || '';
326 if (replyParticipant && myNums.has(numOf(replyParticipant))) {
327 repliedToMe = true;
328 } else if (recentlySentIds.has(contextInfo.stanzaId)) {
329 repliedToMe = true;
330 }
331 }
332
333 if (WHATSAPP_DEBUG && (mentionedJids.length > 0 || repliedToMe)) {
334 try { console.log(JSON.stringify({ event: 'mention_reply_check', myNums: [...myNums], mentionedJids, mentionedMe, repliedToMe, stanzaId: contextInfo?.stanzaId })); } catch {}
335 }
336 }
337
338 // Resolve sender number (LID -> phone if possible)
339 const resolvedSender = resolveNumber(senderNumber);
340
341 // Resolve group name from metadata cache or fetch
342 let chatName;
343 if (isGroup) {
344 if (groupNameCache[chatId]) {
345 chatName = groupNameCache[chatId];
346 } else {
347 try {
348 const meta = await sock.groupMetadata(chatId);
349 chatName = meta.subject || chatId.split('@')[0];
350 groupNameCache[chatId] = chatName;
351 } catch {
352 chatName = chatId.split('@')[0];
353 }
354 }
355 } else {
356 chatName = msg.pushName || resolvedSender;
357 }
358
359 // Strip bot's own @mention from body so agent gets clean text
360 let cleanBody = body;
361 if (isGroup && mentionedMe && sock.user) {
362 const myNum = numOf(sock.user.id || '');
363 const myLidNum = numOf(sock.user.lid || '');
364 // Remove @number or @lid patterns matching the bot
365 for (const n of [myNum, myLidNum, resolveNumber(myNum), resolveNumber(myLidNum)]) {
366 if (n) cleanBody = cleanBody.replace(new RegExp(`@${n}\\b`, 'g'), '').trim();
367 }
368 }
369
370 const event = {
371 messageId: msg.key.id,
372 chatId,
373 senderId,
374 senderNumber: resolvedSender,
375 senderName: msg.pushName || resolvedSender,
376 chatName,
377 isGroup,
378 mentionedMe,
379 repliedToMe,
380 body: cleanBody,
381 hasMedia,
382 mediaType,
383 mediaUrls,
384 timestamp: msg.messageTimestamp,
385 };
386
387 // Store raw message for reply quoting
388 messageStore.set(msg.key.id, msg);
389 if (messageStore.size > MAX_STORED_MESSAGES) {
390 messageStore.delete(messageStore.keys().next().value);
391 }
392
393 messageQueue.push(event);
394 if (messageQueue.length > MAX_QUEUE_SIZE) {
395 messageQueue.shift();
396 }
397 }
398 });
399 }
400
401 // HTTP server
402 const app = express();
403 app.use(express.json());
404
405 // Poll for new messages
406 app.get('/messages', (req, res) => {
407 const msgs = messageQueue.splice(0, messageQueue.length);
408 res.json(msgs);
409 });
410
411 // Send a message
412 app.post('/send', async (req, res) => {
413 if (!sock || connectionState !== 'connected') {
414 return res.status(503).json({ error: 'Not connected to WhatsApp' });
415 }
416
417 const { chatId, message, replyTo } = req.body;
418 if (!chatId || !message) {
419 return res.status(400).json({ error: 'chatId and message are required' });
420 }
421
422 try {
423 const opts = {};
424 if (replyTo && messageStore.has(replyTo)) {
425 opts.quoted = messageStore.get(replyTo);
426 }
427 const sent = await sock.sendMessage(chatId, { text: message }, opts);
428
429 if (sent?.key?.id) {
430 recentlySentIds.add(sent.key.id);
431 if (recentlySentIds.size > MAX_RECENT_IDS) {
432 recentlySentIds.delete(recentlySentIds.values().next().value);
433 }
434 }
435
436 res.json({ success: true, messageId: sent?.key?.id });
437 } catch (err) {
438 res.status(500).json({ error: err.message });
439 }
440 });
441
442 // Edit a previously sent message
443 app.post('/edit', async (req, res) => {
444 if (!sock || connectionState !== 'connected') {
445 return res.status(503).json({ error: 'Not connected to WhatsApp' });
446 }
447
448 const { chatId, messageId, message } = req.body;
449 if (!chatId || !messageId || !message) {
450 return res.status(400).json({ error: 'chatId, messageId, and message are required' });
451 }
452
453 try {
454 const key = { id: messageId, fromMe: true, remoteJid: chatId };
455 await sock.sendMessage(chatId, { text: message, edit: key });
456 res.json({ success: true });
457 } catch (err) {
458 res.status(500).json({ error: err.message });
459 }
460 });
461
462 // MIME type map and media type inference
463 const MIME_MAP = {
464 jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
465 webp: 'image/webp', gif: 'image/gif',
466 mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
467 mkv: 'video/x-matroska', '3gp': 'video/3gpp',
468 pdf: 'application/pdf',
469 doc: 'application/msword',
470 docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
471 xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
472 };
473
474 function inferMediaType(ext) {
475 if (['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(ext)) return 'image';
476 if (['mp4', 'mov', 'avi', 'mkv', '3gp'].includes(ext)) return 'video';
477 if (['ogg', 'opus', 'mp3', 'wav', 'm4a'].includes(ext)) return 'audio';
478 return 'document';
479 }
480
481 // Send media natively
482 app.post('/send-media', async (req, res) => {
483 if (!sock || connectionState !== 'connected') {
484 return res.status(503).json({ error: 'Not connected to WhatsApp' });
485 }
486
487 const { chatId, filePath, mediaType, caption, fileName } = req.body;
488 if (!chatId || !filePath) {
489 return res.status(400).json({ error: 'chatId and filePath are required' });
490 }
491
492 try {
493 if (!existsSync(filePath)) {
494 return res.status(404).json({ error: `File not found: ${filePath}` });
495 }
496
497 const buffer = readFileSync(filePath);
498 const ext = filePath.toLowerCase().split('.').pop();
499 const type = mediaType || inferMediaType(ext);
500 let msgPayload;
501
502 switch (type) {
503 case 'image':
504 msgPayload = { image: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'image/jpeg' };
505 break;
506 case 'video':
507 msgPayload = { video: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'video/mp4' };
508 break;
509 case 'audio': {
510 const audioMime = (ext === 'ogg' || ext === 'opus') ? 'audio/ogg; codecs=opus' : 'audio/mpeg';
511 msgPayload = { audio: buffer, mimetype: audioMime, ptt: ext === 'ogg' || ext === 'opus' };
512 break;
513 }
514 case 'document':
515 default:
516 msgPayload = {
517 document: buffer,
518 fileName: fileName || path.basename(filePath),
519 caption: caption || undefined,
520 mimetype: MIME_MAP[ext] || 'application/octet-stream',
521 };
522 break;
523 }
524
525 const sent = await sock.sendMessage(chatId, msgPayload);
526
527 if (sent?.key?.id) {
528 recentlySentIds.add(sent.key.id);
529 if (recentlySentIds.size > MAX_RECENT_IDS) {
530 recentlySentIds.delete(recentlySentIds.values().next().value);
531 }
532 }
533
534 res.json({ success: true, messageId: sent?.key?.id });
535 } catch (err) {
536 res.status(500).json({ error: err.message });
537 }
538 });
539
540 // Typing indicator
541 app.post('/typing', async (req, res) => {
542 if (!sock || connectionState !== 'connected') {
543 return res.status(503).json({ error: 'Not connected' });
544 }
545
546 const { chatId, status } = req.body;
547 if (!chatId) return res.status(400).json({ error: 'chatId required' });
548
549 try {
550 await sock.sendPresenceUpdate(status === 'paused' ? 'paused' : 'composing', chatId);
551 res.json({ success: true });
552 } catch (err) {
553 res.json({ success: false });
554 }
555 });
556
557 // Chat info
558 app.get('/chat/:id', async (req, res) => {
559 const chatId = req.params.id;
560 const isGroup = chatId.endsWith('@g.us');
561
562 if (isGroup && sock) {
563 try {
564 const metadata = await sock.groupMetadata(chatId);
565 return res.json({
566 name: metadata.subject,
567 isGroup: true,
568 participants: metadata.participants.map(p => p.id),
569 });
570 } catch {
571 // Fall through to default
572 }
573 }
574
575 res.json({
576 name: chatId.replace(/@.*/, ''),
577 isGroup,
578 participants: [],
579 });
580 });
581
582 // QR code for web UI pairing
583 app.get('/qr', (req, res) => {
584 if (connectionState === 'connected') {
585 return res.json({ status: 'connected', qr: null });
586 }
587 if (latestQrDataUrl) {
588 return res.json({ status: 'waiting_scan', qr: latestQrDataUrl });
589 }
590 res.json({ status: 'waiting_qr', qr: null });
591 });
592
593 // Health check
594 app.get('/health', (req, res) => {
595 res.json({
596 status: connectionState,
597 queueLength: messageQueue.length,
598 uptime: process.uptime(),
599 });
600 });
601
602 // Start
603 if (PAIR_ONLY) {
604 console.log('[bridge] WhatsApp pairing mode');
605 console.log(`[bridge] Session: ${SESSION_DIR}`);
606 console.log();
607 startSocket();
608 } else {
609 app.listen(PORT, '127.0.0.1', () => {
610 console.log(`[bridge] WhatsApp bridge listening on port ${PORT} (mode: ${MODE})`);
611 console.log(`[bridge] Session: ${SESSION_DIR}`);
612 console.log();
613 startSocket();
614 });
615 }