- 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
388 lines
15 KiB
JavaScript
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();
|
|
});
|