| 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 | } |