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
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion } = require('@whiskeysockets/baileys');
|
||||
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());
|
||||
@@ -17,15 +19,117 @@ 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, printQRInTerminal: true, browser: ['Nexus POS', 'Chrome', '120.0'] });
|
||||
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) => {
|
||||
@@ -36,43 +140,248 @@ async function connectWhatsApp() {
|
||||
console.log(`[Tenant ${TENANT_ID}] QR code generated!`);
|
||||
}
|
||||
if (connection === 'close') {
|
||||
clearWatchdog();
|
||||
connectionState = 'disconnected';
|
||||
qrCode = null;
|
||||
const reason = lastDisconnect?.error?.output?.statusCode;
|
||||
if (reason !== DisconnectReason.loggedOut) { setTimeout(connectWhatsApp, 5000); }
|
||||
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();
|
||||
}
|
||||
if (connection === 'open') { connectionState = 'open'; qrCode = null; console.log(`[Tenant ${TENANT_ID}] Connected!`); }
|
||||
});
|
||||
|
||||
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 text = msg.message?.conversation || msg.message?.extendedTextMessage?.text || '';
|
||||
console.log(`[Tenant ${TENANT_ID}] From ${phone}: ${text}`);
|
||||
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 {
|
||||
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 } }) });
|
||||
} catch (e) { console.log(`[Tenant ${TENANT_ID}] Webhook failed:`, e.message); }
|
||||
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 }));
|
||||
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) connectWhatsApp(); res.json({ state: connectionState }); });
|
||||
app.post('/send', async (req, res) => {
|
||||
if (connectionState !== 'open') return res.status(400).json({ error: 'Not connected' });
|
||||
const { phone, message } = req.body;
|
||||
const jid = phone.includes('@') ? phone : phone + '@s.whatsapp.net';
|
||||
try { const r = await sock.sendMessage(jid, { text: message }); res.json({ success: true, id: r.key.id }); }
|
||||
catch (e) { res.status(500).json({ error: e.message }); }
|
||||
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('/logout', async (req, res) => { if (sock) { await sock.logout(); sock = null; } qrCode = null; connectionState = 'disconnected'; res.json({ state: 'disconnected' }); });
|
||||
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';
|
||||
|
||||
app.listen(PORT, () => { console.log(`[Tenant ${TENANT_ID}] WhatsApp Bridge on port ${PORT}`); connectWhatsApp(); });
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user