Files
Autoparts-DB/pos/whatsapp-bridge-server.js
consultoria-as a236187f3a feat: MercadoLibre integration + inventory bulk publish + WhatsApp bridge fixes
- Add MercadoLibre OAuth, listings, orders, webhooks and category search
- New marketplace_external_bp.py, meli_service.py, marketplace_external_service.py
- New marketplace_external.html/js with ML management UI
- Inventory: bulk publish to ML with category autocomplete, listing type and shipping selectors
- Inventory: new .btn--meli styles, select/label CSS fixes
- WhatsApp bridge: rate limiting, 440/515/408 error handling, stale watchdog
- DB migration v3.4_meli_integration.sql for marketplace_listings, orders, sync_queue
- Add Celery tasks for ML sync and webhook processing
- Sidebar: MercadoLibre navigation link
2026-05-26 04:24:07 +00:00

388 lines
15 KiB
JavaScript

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();
});