const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage } = require('@whiskeysockets/baileys'); const express = require('express'); const QRCode = require('qrcode'); const pino = require('pino'); const fs = require('fs'); const path = require('path'); const app = express(); app.use(express.json()); // Configurable via environment variables const PORT = process.env.PORT || 21465; const API_KEY = process.env.API_KEY || 'nexus-wpp-secret-2026'; const TENANT_ID = process.env.TENANT_ID || ''; const WEBHOOK_BASE = process.env.WEBHOOK_BASE || 'http://localhost:5001/pos/api/whatsapp/webhook'; const WEBHOOK_URL = TENANT_ID ? `${WEBHOOK_BASE}?tenant_id=${TENANT_ID}` : WEBHOOK_BASE; const AUTH_DIR = process.env.AUTH_DIR || '/app/auth'; let sock = null; let qrCode = null; let connectionState = 'disconnected'; let retry440Count = 0; let lastConnectAttempt = 0; const logger = pino({ level: process.env.LOG_LEVEL || 'warn' }); // Queue for outgoing messages when disconnected const sendQueue = []; let queueTimer = null; let connectWatchdog = null; let staleWatchdog = null; const WATCHDOG_MS = 90000; const STALE_MS = 90000; let lastActivity = Date.now(); function updateActivity() { lastActivity = Date.now(); } function clearAuthState() { try { if (fs.existsSync(AUTH_DIR)) { fs.readdirSync(AUTH_DIR).forEach(f => { try { fs.unlinkSync(path.join(AUTH_DIR, f)); } catch (e) {} }); } console.log(`[Tenant ${TENANT_ID}] Auth state cleared`); } catch (e) { console.error(`[Tenant ${TENANT_ID}] Failed to clear auth:`, e.message); } } function flushSendQueue() { if (!sock || connectionState !== 'open') return; while (sendQueue.length > 0) { const item = sendQueue.shift(); try { sock.sendMessage(item.jid, { text: item.message }); console.log(`[Tenant ${TENANT_ID}] Flushed queued message to ${item.jid}`); } catch (e) { console.error(`[Tenant ${TENANT_ID}] Failed to flush queued message:`, e.message); sendQueue.unshift(item); break; } } } function clearWatchdog() { if (connectWatchdog) { clearTimeout(connectWatchdog); connectWatchdog = null; } if (staleWatchdog) { clearInterval(staleWatchdog); staleWatchdog = null; } } function scheduleStaleWatchdog() { if (staleWatchdog) clearInterval(staleWatchdog); staleWatchdog = setInterval(() => { if (connectionState === 'open' && (Date.now() - lastActivity > STALE_MS)) { console.log(`[Tenant ${TENANT_ID}] Stale watchdog: no activity for ${STALE_MS/1000}s while open, forcing reconnect`); try { sock?.ws?.close(); } catch (e) {} sock = null; connectionState = 'disconnected'; setTimeout(connectWhatsApp, 30000); } }, 30000); } function scheduleWatchdog() { clearWatchdog(); connectWatchdog = setTimeout(() => { if (connectionState !== 'open') { console.log(`[Tenant ${TENANT_ID}] Watchdog: connection not stable after ${WATCHDOG_MS/1000}s, forcing reconnect`); try { sock?.ws?.close(); } catch (e) {} sock = null; connectionState = 'disconnected'; setTimeout(connectWhatsApp, 30000); } }, WATCHDOG_MS); } async function connectWhatsApp() { // Rate limit: max 1 attempt per 30 seconds to avoid WhatsApp throttling const now = Date.now(); if (now - lastConnectAttempt < 30000) { const wait = 30000 - (now - lastConnectAttempt); console.log(`[Tenant ${TENANT_ID}] Rate limiting: waiting ${wait/1000}s before next attempt`); setTimeout(connectWhatsApp, wait); return; } lastConnectAttempt = now; clearWatchdog(); const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR); const { version } = await fetchLatestBaileysVersion(); console.log(`[Tenant ${TENANT_ID}] Connecting with Baileys v` + version.join('.')); connectionState = 'connecting'; scheduleWatchdog(); sock = makeWASocket({ version, auth: state, logger, // Use a generic Ubuntu + Chrome fingerprint — less suspicious than raw Linux browser: ['Ubuntu', 'Chrome', '120.0.0.0'], defaultQueryTimeoutMs: 60000, keepAliveIntervalMs: 15000, markOnlineOnConnect: false, syncFullHistory: false, shouldSyncHistoryMessage: () => false, shouldIgnoreJid: (jid) => false, retryRequestDelayMs: 250, maxMsgRetryCount: 2, connectTimeoutMs: 60000, emitOwnEvents: false, }); sock.ev.on('creds.update', saveCreds); sock.ev.on('connection.update', async (update) => { const { connection, lastDisconnect, qr } = update; if (qr) { qrCode = await QRCode.toDataURL(qr); connectionState = 'qr'; console.log(`[Tenant ${TENANT_ID}] QR code generated!`); } if (connection === 'close') { clearWatchdog(); connectionState = 'disconnected'; qrCode = null; const statusCode = lastDisconnect?.error?.output?.statusCode; // Baileys wraps some errors differently; try both paths const reason = statusCode || lastDisconnect?.error?.statusCode; console.log(`[Tenant ${TENANT_ID}] Disconnected, reason:`, reason); if (reason === DisconnectReason.loggedOut) { console.log(`[Tenant ${TENANT_ID}] Logged out — clearing auth`); clearAuthState(); sock = null; retry440Count = 0; return; } if (reason === 440) { // 440 = conflict/replaced. The session data is permanently invalid. // Clean auth immediately and wait 5 min so WhatsApp forgets the old session. console.log(`[Tenant ${TENANT_ID}] 440 Session replaced — clearing auth, waiting 5 min`); clearAuthState(); sock = null; retry440Count++; const delay = retry440Count >= 3 ? 600000 : 300000; // 10 min after 3 failures, else 5 min setTimeout(connectWhatsApp, delay); return; } if (reason === 515) { // 515 = stream error, often precedes 440. Treat same as 440. console.log(`[Tenant ${TENANT_ID}] 515 Stream error — clearing auth, waiting 5 min`); clearAuthState(); sock = null; setTimeout(connectWhatsApp, 300000); return; } if (reason === 428) { console.log(`[Tenant ${TENANT_ID}] 428 Server terminated — waiting 60s`); sock = null; setTimeout(connectWhatsApp, 60000); return; } if (reason === 408) { // 408 during init queries usually means the server is overloaded // or our auth is partially invalid. Clear auth if this happens repeatedly. console.log(`[Tenant ${TENANT_ID}] 408 Timeout — waiting 60s`); sock = null; retry440Count++; if (retry440Count >= 5) { console.log(`[Tenant ${TENANT_ID}] Too many timeouts — clearing auth for fresh QR`); clearAuthState(); retry440Count = 0; setTimeout(connectWhatsApp, 300000); return; } setTimeout(connectWhatsApp, 60000); return; } // Any other error — wait 30s and retry console.log(`[Tenant ${TENANT_ID}] Reconnecting in 30s... (reason: ${reason})`); sock = null; setTimeout(connectWhatsApp, 30000); } if (connection === 'open') { clearWatchdog(); connectionState = 'open'; qrCode = null; retry440Count = 0; updateActivity(); scheduleStaleWatchdog(); console.log(`[Tenant ${TENANT_ID}] Connected!`); flushSendQueue(); } }); sock.ev.on('messages.upsert', async ({ messages }) => { for (const msg of messages) { if (msg.key.fromMe) continue; const phone = msg.key.remoteJid.replace('@s.whatsapp.net', ''); const message = msg.message || {}; let media_kind = 'text'; let media_base64 = null; let media_mimetype = null; let media_caption = null; let media_ptt = false; let latitude = null; let longitude = null; let text = ''; if (message.conversation) { text = message.conversation; } else if (message.extendedTextMessage) { text = message.extendedTextMessage.text || ''; } else if (message.imageMessage) { media_kind = 'image'; media_mimetype = message.imageMessage.mimetype; media_caption = message.imageMessage.caption || ''; text = media_caption; } else if (message.videoMessage) { media_kind = 'video'; media_mimetype = message.videoMessage.mimetype; media_caption = message.videoMessage.caption || ''; text = media_caption; } else if (message.audioMessage) { media_kind = 'audio'; media_mimetype = message.audioMessage.mimetype; media_ptt = message.audioMessage.ptt || false; } else if (message.locationMessage) { media_kind = 'location'; latitude = message.locationMessage.degreesLatitude; longitude = message.locationMessage.degreesLongitude; text = `Ubicación: ${latitude}, ${longitude}`; } if (['image', 'video', 'audio'].includes(media_kind)) { try { const buffer = await downloadMediaMessage(msg, 'buffer', {}, { logger: pino({ level: 'silent' }) }); if (buffer) media_base64 = buffer.toString('base64'); } catch (e) { console.log(`[Tenant ${TENANT_ID}] Media download failed:`, e.message); } } updateActivity(); console.log(`[Tenant ${TENANT_ID}] From ${phone}: [${media_kind}] ${text.substring(0, 80)}`); try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); const resp = await fetch(WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'messages.upsert', data: { key: msg.key, message: msg.message, messageTimestamp: msg.messageTimestamp, media_kind, media_base64, media_mimetype, media_caption, media_ptt, latitude, longitude, push_name: msg.pushName || '' } }), signal: controller.signal }); clearTimeout(timeoutId); if (resp.ok) { console.log(`[Tenant ${TENANT_ID}] Webhook delivered OK (${resp.status})`); } else { console.log(`[Tenant ${TENANT_ID}] Webhook returned ${resp.status}`); } } catch (e) { console.log(`[Tenant ${TENANT_ID}] Webhook failed:`, e.message); } } }); } app.get('/', (req, res) => res.json({ status: 'ok', service: 'Nexus WhatsApp Bridge', tenant: TENANT_ID, state: connectionState, queued: sendQueue.length })); app.get('/status', (req, res) => res.json({ state: connectionState, hasQr: !!qrCode })); app.get('/qr', (req, res) => { if (connectionState === 'open') return res.json({ state: 'open', message: 'Already connected' }); if (!qrCode) return res.json({ state: connectionState, qr: null, message: 'QR not ready' }); res.json({ state: 'qr', qr: qrCode }); }); app.post('/connect', async (req, res) => { if (sock) { try { await sock.logout(); } catch (e) {} sock = null; } retry440Count = 0; clearAuthState(); qrCode = null; connectionState = 'connecting'; connectWhatsApp(); res.json({ state: connectionState }); }); app.post('/send', async (req, res) => { const { phone, message } = req.body; if (!phone || !message) { return res.status(400).json({ error: 'phone and message required' }); } const jid = phone.includes('@') ? phone : phone + '@s.whatsapp.net'; if (connectionState !== 'open' || !sock) { sendQueue.push({ jid, message }); console.log(`[Tenant ${TENANT_ID}] Message queued for ${jid} (state: ${connectionState}, queue size: ${sendQueue.length})`); return res.status(202).json({ queued: true, state: connectionState }); } try { const r = await sock.sendMessage(jid, { text: message }); res.json({ success: true, id: r.key.id }); } catch (e) { sendQueue.push({ jid, message }); console.log(`[Tenant ${TENANT_ID}] Send failed, queued for retry:`, e.message); res.status(202).json({ queued: true, error: e.message }); } }); app.post('/send-image', async (req, res) => { const { phone, caption, base64 } = req.body; if (!phone || !base64) { return res.status(400).json({ error: 'phone and base64 required' }); } const jid = phone.includes('@') ? phone : phone + '@s.whatsapp.net'; if (connectionState !== 'open' || !sock) { return res.status(400).json({ error: 'Not connected' }); } try { const buffer = Buffer.from(base64, 'base64'); const r = await sock.sendMessage(jid, { image: buffer, caption: caption || '' }); res.json({ success: true, id: r.key.id }); } catch (e) { console.log(`[Tenant ${TENANT_ID}] Send image failed:`, e.message); res.status(500).json({ error: e.message }); } }); app.post('/logout', async (req, res) => { if (sock) { await sock.logout(); sock = null; } clearAuthState(); qrCode = null; connectionState = 'disconnected'; retry440Count = 0; res.json({ state: 'disconnected' }); }); app.listen(PORT, () => { console.log(`[Tenant ${TENANT_ID}] WhatsApp Bridge on port ${PORT}`); connectWhatsApp(); });