/** * MADOKA BOT - V69 (Post-Message Anti-Enterramiento para Cierres) * CRM Quantico + Auto Media + Exact Docs + Groq + RAG */ const express = require('express'); const path = require('path'); const fs = require('fs'); const fetch = require('node-fetch'); const http = require('http'); const { Server } = require('socket.io'); const qrcode = require('qrcode'); const { Client, LocalAuth, MessageMedia } = require('whatsapp-web.js'); let pdfParse; try { pdfParse = require('pdf-parse'); } catch(e) { console.log("⚠️ Módulo 'pdf-parse' no encontrado."); } const ROOT = __dirname; const DEBUG_FILE = path.join(ROOT, 'DEBUG_LOCAL.txt'); const START_TIME = Date.now(); const SYSTEM_VERSION = "MADOKA V69.0 (Quantico System)"; const log = (msg) => { const time = new Date().toLocaleString('es-DO', { timeZone: 'America/Santo_Domingo' }); const line = `[${time}] ${msg}\n`; fs.appendFileSync(DEBUG_FILE, line); console.log(line); }; if (fs.existsSync(DEBUG_FILE)) fs.writeFileSync(DEBUG_FILE, ""); process.on('uncaughtException', err => log(`[FATAL UNCAUGHT] ${err.stack}`)); process.on('unhandledRejection', err => log(`[FATAL REJECTION] ${err.stack || err}`)); log("--- INICIANDO MADOKA V69 (QUANTICO RESTAURANT) ---"); try { require('dotenv').config({ path: path.join(ROOT, '.env') }); } catch (e) {} const app = express(); const server = http.createServer(app); const io = new Server(server, { cors: { origin: "*", methods: ["GET", "POST", "PUT", "DELETE"], allowedHeaders: ["ngrok-skip-browser-warning", "Content-Type"] } }); const PORT = process.env.PORT || 3000; app.use(require('cors')({ origin: "*", allowedHeaders: ['Content-Type', 'ngrok-skip-browser-warning', 'Authorization'] })); const AUTH_DIR = path.join(ROOT, 'madoka_auth'); const DATA_DIR = path.join(ROOT, 'data'); const PUBLIC_DIR = path.join(ROOT, 'public'); const MEDIA_DIR = path.join(ROOT, 'media'); const DOCS_DIR = path.join(MEDIA_DIR, 'docs'); [AUTH_DIR, DATA_DIR, PUBLIC_DIR, MEDIA_DIR, DOCS_DIR].forEach(dir => { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); }); const FILES = { STATS: path.join(DATA_DIR, 'stats.json'), RULES: path.join(DATA_DIR, 'rules.json'), LOGS: path.join(DATA_DIR, 'logs.json'), USERS: path.join(DATA_DIR, 'users.json'), CONTEXT: path.join(DATA_DIR, 'context.txt'), REMINDERS: path.join(DATA_DIR, 'reminders.json'), CONTACTS: path.join(DATA_DIR, 'contacts.json'), COMMANDS: path.join(DATA_DIR, 'commands.json'), SETTINGS: path.join(DATA_DIR, 'settings.json') }; // 🧠 CONFIGURACIÓN POR DEFECTO CON CONTROL DE TIEMPOS Y EMPRESA const DEFAULT_SETTINGS = { companyName: "Quantico", delayGeneral: 20000, delayInfo: 10000, delayMedia: 1500, delayClosing: 10000 }; const db = { read: (file, defaultData) => { if (!fs.existsSync(file)) fs.writeFileSync(file, JSON.stringify(defaultData, null, 2)); try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { return defaultData; } }, write: (file, data) => fs.writeFileSync(file, JSON.stringify(data, null, 2)) }; const cleanJid = (jid) => { if (!jid) return ""; if (jid.includes('status@broadcast')) return 'status@broadcast'; const num = jid.replace(/[^0-9]/g, ''); if(!num) return jid; return num + '@c.us'; }; function findFileFuzzy(dir, targetName) { if (!fs.existsSync(dir)) return null; const files = fs.readdirSync(dir); const targetClean = targetName.toLowerCase().replace('.txt', '').trim(); for (let file of files) { const fileClean = file.toLowerCase().replace('.txt', '').trim(); if (fileClean === targetClean || fileClean.includes(targetClean)) { return file; } } return null; } if (!fs.existsSync(FILES.CONTEXT)) fs.writeFileSync(FILES.CONTEXT, "Eres MADOKA."); db.read(FILES.RULES, []); db.read(FILES.LOGS, []); db.read(FILES.STATS, { total_mensajes: 0 }); db.read(FILES.REMINDERS, []); db.read(FILES.CONTACTS, []); db.read(FILES.COMMANDS, []); db.read(FILES.SETTINGS, DEFAULT_SETTINGS); let rawUsers = db.read(FILES.USERS, []); let strictUniqueUsers = {}; rawUsers.forEach(u => { let cleanId = cleanJid(u.id); if (!strictUniqueUsers[cleanId]) { u.id = cleanId; u.funnel_stage = u.funnel_stage || 'new'; u.score = u.score || 10; u.sentiment = u.sentiment || 'neutral'; u.tags = u.tags || []; u.received_media = u.received_media || []; if(u.name && u.name.includes("906") && u.name.includes("4631")) { u.name = "Usuario"; } strictUniqueUsers[cleanId] = u; } else { if (u.is_admin) strictUniqueUsers[cleanId].is_admin = true; if (u.timestamp > strictUniqueUsers[cleanId].timestamp) { strictUniqueUsers[cleanId].last_msg = u.last_msg; strictUniqueUsers[cleanId].timestamp = u.timestamp; strictUniqueUsers[cleanId].date_only = u.date_only; if(u.last_msg_id) strictUniqueUsers[cleanId].last_msg_id = u.last_msg_id; } if (u.name && !u.name.includes("906") && u.name !== "Usuario" && u.name !== strictUniqueUsers[cleanId].name) { strictUniqueUsers[cleanId].name = u.name; } else if (strictUniqueUsers[cleanId].name && strictUniqueUsers[cleanId].name.includes("906") && strictUniqueUsers[cleanId].name.includes("4631")) { strictUniqueUsers[cleanId].name = "Usuario"; } if(!strictUniqueUsers[cleanId].funnel_stage) strictUniqueUsers[cleanId].funnel_stage = 'new'; if(!strictUniqueUsers[cleanId].score) strictUniqueUsers[cleanId].score = 10; if(!strictUniqueUsers[cleanId].sentiment) strictUniqueUsers[cleanId].sentiment = 'neutral'; if(!strictUniqueUsers[cleanId].tags) strictUniqueUsers[cleanId].tags = []; if(!strictUniqueUsers[cleanId].received_media) strictUniqueUsers[cleanId].received_media = []; strictUniqueUsers[cleanId].msg_count += (u.msg_count || 1); } }); db.write(FILES.USERS, Object.values(strictUniqueUsers)); let qrDataURL = null; let isConnected = false; const client = new Client({ authStrategy: new LocalAuth({ dataPath: AUTH_DIR }), puppeteer: { headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--no-first-run', '--no-zygote', '--single-process', '--disable-gpu'] } }); async function sendSafeMessage(jid, text, originalMsgObj = null) { let finalJid = cleanJid(jid); try { if (originalMsgObj) { try { const chat = await originalMsgObj.getChat(); await chat.sendMessage(text); return; } catch(e) {} } try { const chat = await client.getChatById(finalJid); if (chat) { await chat.sendMessage(text); return; } } catch(e) {} try { const cleanNumber = finalJid.split('@')[0]; const numberId = await client.getNumberId(cleanNumber); if (numberId) { await client.sendMessage(numberId._serialized, text); return; } } catch(e) {} await client.sendMessage(finalJid, text); } catch (error) { log(`❌ Error enviando texto a ${finalJid}: ${error.message}`); throw new Error("Fallo de conexión con WhatsApp al enviar texto."); } } async function sendSafeMedia(jid, mediaToSend, originalMsgObj = null) { let finalJid = cleanJid(jid); try { if (originalMsgObj) { try { const chat = await originalMsgObj.getChat(); await chat.sendMessage(mediaToSend); return; } catch(e) {} } try { const chat = await client.getChatById(finalJid); if (chat) { await chat.sendMessage(mediaToSend); return; } } catch(e) {} try { const cleanNumber = finalJid.split('@')[0]; const numberId = await client.getNumberId(cleanNumber); if (numberId) { await client.sendMessage(numberId._serialized, mediaToSend); return; } } catch(e) {} await client.sendMessage(finalJid, mediaToSend); } catch (error) { log(`❌ Error crítico enviando media a ${finalJid}: ${error.message}`); } } app.use(express.json({ limit: '10mb' })); app.use(express.static(PUBLIC_DIR)); io.on('connection', (socket) => { socket.on('send_message_from_panel', async (data) => { if (isConnected && data.jid && data.text) { try { await sendSafeMessage(data.jid, data.text); const logs = db.read(FILES.LOGS, []); const time = new Date().toLocaleTimeString('es-DO', { timeZone: 'America/Santo_Domingo' }); const logEntry = { jid: data.jid, from: "Tú (Panel)", msg: data.text, date: time, timestamp: Date.now(), type: 'out' }; logs.push(logEntry); db.write(FILES.LOGS, logs.slice(-5000)); io.emit('new_message_update', logEntry); } catch (e) {} } }); }); app.get('/log', (req, res) => res.send(`
${fs.readFileSync(DEBUG_FILE, 'utf8')}
`)); app.get('/api/settings', (req, res) => res.json(db.read(FILES.SETTINGS, DEFAULT_SETTINGS))); app.post('/api/settings', (req, res) => { const current = db.read(FILES.SETTINGS, DEFAULT_SETTINGS); if(req.body.companyName !== undefined) current.companyName = req.body.companyName; if(req.body.delayGeneral !== undefined) current.delayGeneral = req.body.delayGeneral; if(req.body.delayInfo !== undefined) current.delayInfo = req.body.delayInfo; if(req.body.delayMedia !== undefined) current.delayMedia = req.body.delayMedia; if(req.body.delayClosing !== undefined) current.delayClosing = req.body.delayClosing; db.write(FILES.SETTINGS, current); io.emit('settings_updated', current); res.json({ success: true }); }); app.get('/api/system-info', (req, res) => { const uptimeMs = Date.now() - START_TIME; const hours = Math.floor(uptimeMs / 3600000); const minutes = Math.floor((uptimeMs % 3600000) / 60000); const settings = db.read(FILES.SETTINGS, DEFAULT_SETTINGS); res.json({ version: SYSTEM_VERSION, company: settings.companyName, uptime: `${hours} horas, ${minutes} minutos`, status: isConnected ? "Operativo 🟢" : "Desconectado 🔴" }); }); app.post('/api/sync-contacts', async (req, res) => { if(!isConnected) return res.json({ success: false, error: "WhatsApp no está conectado." }); try { const contacts = await client.getContacts(); const saved = contacts.filter(c => c.isUser).map(c => ({ id: cleanJid(c.id._serialized), name: c.name || c.pushname || "Desconocido" })); db.write(FILES.CONTACTS, saved); res.json({ success: true, count: saved.length }); } catch(e) { res.json({ success: false, error: e.message }); } }); app.post('/api/sync-chats', async (req, res) => { if(!isConnected) return res.json({ success: false, error: "WhatsApp no está conectado." }); try { const chats = await client.getChats(); let users = db.read(FILES.USERS, []); let added = 0; chats.filter(c => !c.isGroup).forEach(c => { const cId = cleanJid(c.id._serialized); if(!users.find(u => u.id === cId)) { users.push({ id: cId, name: c.name || "Usuario Sync", ia_active: true, is_admin: false, date_only: new Date().toLocaleDateString('es-DO'), msg_count: 0, last_msg: "[Sincronizado]", timestamp: Date.now(), funnel_stage: 'new', score: 10, sentiment: 'neutral', tags: [], received_media: [] }); added++; } }); db.write(FILES.USERS, users); res.json({ success: true, count: added }); } catch(e) { res.json({ success: false, error: e.message }); } }); app.post('/api/clear-learning', (req, res) => { const { jid } = req.body; try { if (jid === 'ALL') { db.write(FILES.LOGS, []); } else { let logs = db.read(FILES.LOGS, []); logs = logs.filter(l => l.jid !== jid); db.write(FILES.LOGS, logs); } res.json({ success: true }); } catch(e) { res.json({ success: false, error: e.message }); } }); app.delete('/api/users/:id', (req, res) => { const targetJid = req.params.id; try { let users = db.read(FILES.USERS, []); users = users.filter(u => u.id !== targetJid); db.write(FILES.USERS, users); let logs = db.read(FILES.LOGS, []); logs = logs.filter(l => l.jid !== targetJid); db.write(FILES.LOGS, logs); io.emit('user_deleted', targetJid); res.json({ success: true }); } catch(e) { res.json({ success: false, error: e.message }); } }); app.get('/api/analytics', (req, res) => { const logs = db.read(FILES.LOGS, []); const users = db.read(FILES.USERS, []); const today = new Date().toLocaleDateString('es-DO', { timeZone: 'America/Santo_Domingo' }); const chartLabels = []; const chartData = []; const daysMap = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb']; const now = new Date(); const tzOffset = now.getTimezoneOffset() * 60000; let msgsIn = 0; let msgsOut = 0; for(let i=6; i>=0; i--) { const d = new Date(now); d.setDate(d.getDate() - i); chartLabels.push(daysMap[d.getDay()]); const startOfDay = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0).getTime(); const endOfDay = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59).getTime(); const dayLogs = logs.filter(l => l.timestamp && l.timestamp >= startOfDay && l.timestamp <= endOfDay); chartData.push(dayLogs.length); if (i === 0) { msgsIn = dayLogs.filter(l => l.type === 'in').length; msgsOut = dayLogs.filter(l => l.type === 'out').length; } } const topUsers = [...users].sort((a,b) => b.msg_count - a.msg_count).slice(0, 5); res.json({ total_mensajes: logs.length, total_usuarios: users.length, hoy: users.filter(u => u.date_only === today).length, msgs_in_today: msgsIn, msgs_out_today: msgsOut, chartLabels, chartData, topUsers }); }); app.get('/api/context', (req, res) => res.json({ text: fs.readFileSync(FILES.CONTEXT, 'utf8') })); app.post('/api/context', (req, res) => { fs.writeFileSync(FILES.CONTEXT, req.body.text); res.json({ success: true }); }); app.get('/api/logs', (req, res) => res.json(db.read(FILES.LOGS, []).slice(-1000))); app.get('/api/rules', (req, res) => res.json(db.read(FILES.RULES, []))); app.get('/api/users', (req, res) => res.json(db.read(FILES.USERS, []))); app.get('/api/docs', (req, res) => { if(!fs.existsSync(DOCS_DIR)) return res.json([]); const files = fs.readdirSync(DOCS_DIR).filter(f => f.endsWith('.pdf') || f.endsWith('.txt')); res.json(files); }); app.post('/api/admin-chat', async (req, res) => { const { message, history } = req.body; const users = db.read(FILES.USERS, []).filter(u => u.id !== 'status@broadcast'); const crmData = users.map(u => ({ nombre: u.name, etapa: u.funnel_stage, calificacion_lead: u.score, humor: u.sentiment, etiquetas: u.tags, ultimo_mensaje: u.last_msg })); const systemPrompt = `Eres MADOKA, Analista de Negocios de la agencia. \nBase de Datos CRM:\n${JSON.stringify(crmData)}\nDa respuestas directas, profesionales y estratégicas. Usa viñetas y emojis.`; let messagesArray = [{ role: "system", content: systemPrompt }]; if (history && history.length > 0) messagesArray = messagesArray.concat(history); messagesArray.push({ role: "user", content: message }); try { const response = await consultarGroqBlindado(messagesArray); res.json({ success: true, response }); } catch (e) { res.json({ success: false, error: e.message }); } }); app.post('/api/summarize', async (req, res) => { const { jid } = req.body; const logs = db.read(FILES.LOGS, []).filter(l => l.jid === cleanJid(jid)).slice(-40); if(logs.length === 0) return res.json({ success: false, error: "No hay historial para resumir." }); const chatText = logs.map(l => `${l.from}: ${l.msg}`).join('\n'); const prompt = `Resume este chat de WhatsApp en 3 viñetas cortas y profesionales. Usa emojis.\n\nHistorial:\n${chatText}`; try { const summary = await consultarGroqBlindado([{ role: "user", content: prompt }]); res.json({ success: true, summary }); } catch (e) { res.json({ success: false, error: e.message }); } }); app.post('/api/force-ai-reply', async (req, res) => { const { jid } = req.body; if(!jid) return res.json({ success: false, error: "Falta JID" }); try { const logs = db.read(FILES.LOGS, []); const userHistory = logs.filter(l => l.jid === cleanJid(jid)).slice(-15).map(l => `${l.from}: ${l.msg}`).join('\n'); const ctx = fs.readFileSync(FILES.CONTEXT, 'utf8'); const rules = db.read(FILES.RULES, []).map(r => `${r.topic}: ${r.content}`).join('\n'); const settings = db.read(FILES.SETTINGS, DEFAULT_SETTINGS); const promptForzado = `=== CONTEXTO DE EMPRESA ===\nEmpresa: ${settings.companyName}\n${ctx}\n\n=== REGLAS ===\n${rules}\n\n=== HISTORIAL RECIENTE CON EL CLIENTE ===\n${userHistory}\n\n=== TU OBJETIVO ===\nRedacta un mensaje natural, amable y persuasivo de seguimiento (follow-up). IMPORTANTE: NO pongas etiquetas ocultas al final ([FUNNEL], [SCORE], etc).`; const aiResponse = await consultarGroqBlindado([{ role: "system", content: promptForzado }]); const finalMsg = aiResponse.replace(/(\n\s*\d+\.\s*)*$/g, '').trim(); await sendSafeMessage(jid, finalMsg); const logEntryOut = { jid: cleanJid(jid), from: "IA (Seguimiento)", msg: finalMsg, date: new Date().toLocaleTimeString('es-DO'), timestamp: Date.now(), type: 'out' }; logs.push(logEntryOut); db.write(FILES.LOGS, logs.slice(-5000)); io.emit('new_message_update', logEntryOut); const users = db.read(FILES.USERS, []); let user = users.find(u => u.id === cleanJid(jid)); if(user) { user.last_msg = new Date().toLocaleString('es-DO'); user.timestamp = Date.now(); db.write(FILES.USERS, users); io.emit('user_updated', user); } res.json({ success: true }); } catch (e) { res.json({ success: false, error: e.message }); } }); app.post('/api/broadcast', async (req, res) => { const { targetStage, message } = req.body; const users = db.read(FILES.USERS, []); const targetUsers = targetStage === 'all' ? users.filter(u => u.id !== 'status@broadcast' && !u.is_admin) : users.filter(u => u.funnel_stage === targetStage && !u.is_admin); if(targetUsers.length === 0) return res.json({ success: false, count: 0 }); res.json({ success: true, count: targetUsers.length }); (async () => { for (let i = 0; i < targetUsers.length; i++) { const u = targetUsers[i]; const delay = Math.floor(Math.random() * (25000 - 10000 + 1)) + 10000; try { await sendSafeMessage(u.id, message); const logs = db.read(FILES.LOGS, []); logs.push({ jid: u.id, from: "MADOKA (Campaña)", msg: message, date: new Date().toLocaleTimeString('es-DO'), timestamp: Date.now(), type: 'out' }); db.write(FILES.LOGS, logs.slice(-5000)); io.emit('new_message_update', logs[logs.length - 1]); } catch (err) {} if (i < targetUsers.length - 1) await new Promise(r => setTimeout(r, delay)); } })(); }); app.post('/api/smart-broadcast', async (req, res) => { const { aiPrompt, message } = req.body; const users = db.read(FILES.USERS, []).filter(u => u.id !== 'status@broadcast' && !u.is_admin); const userList = users.map(u => ({ id: u.id, nombre: u.name, etapa_embudo: u.funnel_stage, ultima_interaccion: new Date(u.timestamp).toISOString(), score_interes: u.score, etiquetas: u.tags })); const systemInstruction = `Actúas como un filtro de base de datos JSON.\nBase de datos actual: ${JSON.stringify(userList)}\nInstrucción: "${aiPrompt}"\nAnaliza y devuelve ÚNICAMENTE un arreglo JSON plano con los "id". Ejemplo: ["1809...1@c.us"]. NO devuelvas texto.`; try { const response = await consultarGroqBlindado([{ role: "user", content: systemInstruction }]); const match = response.match(/\[.*\]/s); if(match) { const targetIds = JSON.parse(match[0]); if(targetIds.length === 0) return res.json({ success: false, error: "Nadie cumple la condición." }); res.json({ success: true, count: targetIds.length }); (async () => { for (let i = 0; i < targetIds.length; i++) { const jid = targetIds[i]; const delay = Math.floor(Math.random() * (25000 - 10000 + 1)) + 10000; try { await sendSafeMessage(jid, message); const logs = db.read(FILES.LOGS, []); logs.push({ jid: jid, from: "MADOKA (Smart IA)", msg: message, date: new Date().toLocaleTimeString('es-DO'), timestamp: Date.now(), type: 'out' }); db.write(FILES.LOGS, logs.slice(-5000)); io.emit('new_message_update', logs[logs.length - 1]); } catch (err) {} if (i < targetIds.length - 1) await new Promise(r => setTimeout(r, delay)); } })(); } else { res.json({ success: false, error: "La IA no devolvió un formato válido."}); } } catch (e) { res.json({ success: false, error: e.message }); } }); app.post('/api/rules', (req, res) => { const rules = db.read(FILES.RULES, []); rules.push({ id: Date.now(), topic: req.body.topic, content: req.body.content }); db.write(FILES.RULES, rules); res.json({ success: true }); }); app.put('/api/rules/:id', (req, res) => { let rules = db.read(FILES.RULES, []); const idx = rules.findIndex(r => r.id == req.params.id); if (idx > -1) { rules[idx].topic = req.body.topic; rules[idx].content = req.body.content; db.write(FILES.RULES, rules); } res.json({ success: true }); }); app.delete('/api/rules/:id', (req, res) => { let rules = db.read(FILES.RULES, []); rules = rules.filter(r => r.id != req.params.id); db.write(FILES.RULES, rules); res.json({ success: true }); }); app.get('/api/commands', (req, res) => res.json(db.read(FILES.COMMANDS, []))); app.post('/api/commands', (req, res) => { const cmds = db.read(FILES.COMMANDS, []); cmds.push({ id: Date.now(), trigger: req.body.trigger.toLowerCase(), type: req.body.type, content: req.body.content, desc: req.body.desc }); db.write(FILES.COMMANDS, cmds); res.json({ success: true }); }); app.delete('/api/commands/:id', (req, res) => { let cmds = db.read(FILES.COMMANDS, []); cmds = cmds.filter(c => c.id != req.params.id); db.write(FILES.COMMANDS, cmds); res.json({ success: true }); }); app.post('/api/users/toggle', (req, res) => { let users = db.read(FILES.USERS, []); const targetId = cleanJid(req.body.id); const idx = users.findIndex(u => u.id === targetId); if (idx > -1) { users[idx].ia_active = req.body.ia_active; db.write(FILES.USERS, users); io.emit('user_updated', users[idx]); } res.json({ success: true }); }); app.post('/api/users/admin', (req, res) => { let users = db.read(FILES.USERS, []); const targetId = cleanJid(req.body.id); const idx = users.findIndex(u => u.id === targetId); if (idx > -1) { users[idx].is_admin = req.body.is_admin; db.write(FILES.USERS, users); io.emit('user_updated', users[idx]); } res.json({ success: true }); }); app.post('/api/users/toggle-all', (req, res) => { let users = db.read(FILES.USERS, []); const newState = req.body.ia_active; users.forEach(u => { if (!u.is_admin) u.ia_active = newState; }); db.write(FILES.USERS, users); io.emit('all_users_updated'); res.json({ success: true }); }); app.get('/api/reminders', (req, res) => res.json(db.read(FILES.REMINDERS, []))); app.post('/api/reminders', (req, res) => { const reminders = db.read(FILES.REMINDERS, []); reminders.push({ id: Date.now(), jid: cleanJid(req.body.jid), text: req.body.text, datetime: parseInt(req.body.datetime), type: req.body.type || 'reminder', triggerMsgId: req.body.triggerMsgId || null }); db.write(FILES.REMINDERS, reminders); res.json({ success: true }); }); app.delete('/api/reminders/:id', (req, res) => { let reminders = db.read(FILES.REMINDERS, []); reminders = reminders.filter(r => r.id != req.params.id); db.write(FILES.REMINDERS, reminders); res.json({ success: true }); }); app.get('/qr', (req, res) => { if (isConnected) return res.send(`

✅ MADOKA ESTÁ CONECTADA

`); if (qrDataURL) return res.send(`

Escanea con WhatsApp

`); res.send(`

Generando QR...

`); }); app.get('/', (req, res) => res.sendFile(path.join(PUBLIC_DIR, 'index.html'))); server.listen(PORT, () => { log(`🌍 Servidor Local corriendo en el puerto ${PORT}`); client.initialize(); iniciarMotorRecordatorios(); }); async function getKnowledgeBase() { let kbText = "=== INFORMACIÓN INTERNA PARA LA IA (NO ENVIAR ESTO TEXTUALMENTE, SOLO USAR PARA RESPONDER DUDAS) ===\n"; if(!fs.existsSync(DOCS_DIR)) return kbText; const files = fs.readdirSync(DOCS_DIR); for(let file of files) { if(file.endsWith('.txt')) { const content = fs.readFileSync(path.join(DOCS_DIR, file), 'utf8'); kbText += `\n--- CONTENIDO DE ${file} ---\n${content}\n`; } } return kbText; } function parseTimeOffset(timeStr) { const now = new Date(); const match = timeStr.match(/^(\d+)\s*(s|seg|segundo|segundos|m|min|minuto|minutos|h|hora|horas|d|dia|días|dias)$/i); if (match) { const val = parseInt(match[1]); const unit = match[2].toLowerCase(); if (unit.startsWith('s')) now.setSeconds(now.getSeconds() + val); if (unit.startsWith('m')) now.setMinutes(now.getMinutes() + val); if (unit.startsWith('h')) now.setHours(now.getHours() + val); if (unit.startsWith('d')) now.setDate(now.getDate() + val); return now; } const parsed = new Date(timeStr); if (!isNaN(parsed)) return parsed; return null; } function iniciarMotorRecordatorios() { setInterval(async () => { if (!isConnected) return; let reminders = db.read(FILES.REMINDERS, []); if(reminders.length === 0) return; const now = Date.now(); let pendientes = reminders.filter(r => r.datetime > now); let vencidos = reminders.filter(r => r.datetime <= now); for (let r of vencidos) { try { let entregado = false; const isScheduled = r.type === 'scheduled'; const finalMsg = isScheduled ? r.text : `⏰ *RECORDATORIO DE MADOKA*\n\n${r.text}`; const logLabel = isScheduled ? "MADOKA (Prog.)" : "MADOKA (Recordatorio)"; if (r.triggerMsgId && !isScheduled) { try { const originalMsg = await client.getMessageById(r.triggerMsgId); if (originalMsg) { await originalMsg.reply(finalMsg); entregado = true; } } catch(e) { } } if (!entregado) await sendSafeMessage(r.jid, finalMsg); const logs = db.read(FILES.LOGS, []); const logEntry = { jid: r.jid, from: logLabel, msg: finalMsg, date: new Date().toLocaleTimeString('es-DO'), timestamp: Date.now(), type: 'out' }; logs.push(logEntry); db.write(FILES.LOGS, logs.slice(-5000)); io.emit('new_message_update', logEntry); } catch (e) { } } if (vencidos.length > 0) { db.write(FILES.REMINDERS, pendientes); io.emit('reminders_updated'); } }, 10000); } // 🧠 TIEMPO DE RESPUESTA EXTENDIDO A 45 SEGUNDOS async function consultarGroqBlindado(messagesArray) { const apiKey = process.env.GROQ_KEY; if (!apiKey) throw new Error("Falta GROQ_KEY"); const modelos = ["llama-3.3-70b-versatile", "llama-3.1-8b-instant"]; const fetchConTimeout = (url, options, timeoutMs = 45000) => { return Promise.race([ globalThis.fetch(url, options), new Promise((_, reject) => setTimeout(() => reject(new Error('TIMEOUT_FORZADO')), timeoutMs)) ]); }; for (let modelo of modelos) { try { const response = await fetchConTimeout(`https://api.groq.com/openai/v1/chat/completions`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: modelo, messages: messagesArray, temperature: 0.7, max_tokens: 800 }) }, 45000); const data = await response.json(); if (data.error) continue; if (data.choices && data.choices[0].message) return data.choices[0].message.content; } catch (e) { } } throw new Error("Groq offline o timeout."); } async function transcribirAudioConGroq(media) { const apiKey = process.env.GROQ_KEY; if (!apiKey) throw new Error("Falta GROQ_KEY"); const formData = new globalThis.FormData(); const buffer = Buffer.from(media.data, 'base64'); const blob = new globalThis.Blob([buffer], { type: media.mimetype }); formData.append('file', blob, 'audio.ogg'); formData.append('model', 'whisper-large-v3-turbo'); formData.append('response_format', 'json'); const response = await globalThis.fetch('https://api.groq.com/openai/v1/audio/transcriptions', { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}` }, body: formData }); const data = await response.json(); if (data.error) throw new Error(data.error.message); return data.text; } async function describirImagenConGroq(media) { const apiKey = process.env.GROQ_KEY; if (!apiKey) throw new Error("Falta GROQ_KEY"); const base64Url = `data:${media.mimetype};base64,${media.data}`; const visionPrompt = `Analiza esta imagen. Si es un comprobante de transferencia bancaria, extrae explícitamente: MONTO, BANCO ORIGEN, BANCO DESTINO y FECHA.`; const response = await globalThis.fetch('https://api.groq.com/openai/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'meta-llama/llama-4-scout-17b-16e-instruct', messages: [{ role: 'user', content: [ { type: 'text', text: visionPrompt }, { type: 'image_url', image_url: { url: base64Url } } ] }], max_tokens: 300, temperature: 0.2 }) }); const data = await response.json(); if (data.error) throw new Error(data.error.message); return data.choices[0].message.content; } client.on('qr', async (qr) => { log('⏳ Generando código QR...'); qrDataURL = await qrcode.toDataURL(qr); }); client.on('ready', async () => { log('✅ WhatsApp Operativo y Sincronizado.'); isConnected = true; qrDataURL = null; }); client.on('disconnected', (reason) => { log(`❌ Cliente desconectado: ${reason}`); isConnected = false; setTimeout(() => { client.initialize(); }, 5000); }); client.on('message_create', async msg => { if (msg.isStatus || msg.id.remote.includes('@g.us')) return; const isIncoming = !msg.fromMe; const targetJid = isIncoming ? cleanJid(msg.from) : cleanJid(msg.to); let textMessage = msg.body || ""; if (!textMessage && msg.hasMedia) { textMessage = "[Archivo Multimedia]"; } if(!textMessage) return; // 🧠 LÓGICA DE ESCUDO DE NOMBRES let userName = "Usuario"; try { if (isIncoming) { const contact = await msg.getContact(); userName = contact.name || contact.pushname || contact.number || "Usuario"; } else { const chat = await msg.getChat(); userName = chat.name || "Usuario"; } } catch(e) {} const timeStr = new Date().toLocaleTimeString('es-DO', { timeZone: 'America/Santo_Domingo' }); const todayStr = new Date().toLocaleDateString('es-DO', { timeZone: 'America/Santo_Domingo' }); const currentTimestamp = Date.now(); const logs = db.read(FILES.LOGS, []); if (!isIncoming) { const recentlyLogged = logs.slice(-10).find(l => l.jid === targetJid && l.msg === textMessage && l.type === 'out' && (currentTimestamp - l.timestamp < 3000)); if (recentlyLogged) return; } const logFrom = isIncoming ? userName : "Tú (Celular/Web)"; const logType = isIncoming ? 'in' : 'out'; const logEntryIn = { jid: targetJid, from: logFrom, msg: textMessage, date: timeStr, timestamp: currentTimestamp, type: logType }; logs.push(logEntryIn); db.write(FILES.LOGS, logs.slice(-5000)); io.emit('new_message_update', logEntryIn); const users = db.read(FILES.USERS, []); let userIndex = users.findIndex(u => u.id === targetJid); let user; if (userIndex === -1) { user = { id: targetJid, name: userName, ia_active: true, is_admin: false, date_only: todayStr, msg_count: 1, last_msg: textMessage.substring(0,50), timestamp: currentTimestamp, last_msg_id: msg.id._serialized, funnel_stage: 'new', score: 10, sentiment: 'neutral', tags: [], received_media: [] }; users.push(user); } else { user = users[userIndex]; user.msg_count++; user.last_msg = textMessage.substring(0,50); user.timestamp = currentTimestamp; if (isIncoming && userName !== "Usuario") { user.name = userName; } else if (user.name === "Usuario" && userName !== "Usuario") { user.name = userName; } user.date_only = todayStr; user.last_msg_id = msg.id._serialized; if(!user.tags) user.tags = []; if(!user.received_media) user.received_media = []; } const deduplicatedUsers = []; const seenMap = new Set(); for (let i = users.length - 1; i >= 0; i--) { if (!seenMap.has(users[i].id)) { seenMap.add(users[i].id); deduplicatedUsers.unshift(users[i]); } } db.write(FILES.USERS, deduplicatedUsers); io.emit('user_updated', user); if (!isIncoming) return; if (msg.hasMedia) { try { const media = await msg.downloadMedia(); if (media) { if (media.mimetype.includes('audio') || msg.type === 'ptt') { textMessage = await transcribirAudioConGroq(media); } else if (media.mimetype.includes('image')) { const descripcion = await describirImagenConGroq(media); textMessage = `${textMessage}\n\n[Imagen adjunta: ${descripcion}]`; } logs[logs.length-1].msg = textMessage; db.write(FILES.LOGS, logs.slice(-5000)); io.emit('new_message_update', logs[logs.length-1]); } } catch (mediaError) {} } const textLower = textMessage.toLowerCase().trim(); const cmds = db.read(FILES.COMMANDS, []); const foundCmd = cmds.find(c => textLower === c.trigger); if (foundCmd) { if (foundCmd.type === 'media') { const folderPath = path.join(MEDIA_DIR, foundCmd.content); if (fs.existsSync(folderPath)) { const files = fs.readdirSync(folderPath); let sentCount = 0; for (let file of files) { if (file.match(/\.(png|jpe?g|gif|pdf|mp4)$/i)) { const filePath = path.join(folderPath, file); const mediaToSend = MessageMedia.fromFilePath(filePath); await sendSafeMedia(targetJid, mediaToSend, msg); sentCount++; } } if (sentCount > 0) { await sendSafeMessage(targetJid, `✅ Se han enviado ${sentCount} archivo(s).`, msg); logs.push({ jid: targetJid, from: "MADOKA (Multimedia)", msg: `[Envió ${sentCount} archivos]`, date: timeStr, timestamp: Date.now(), type: 'out' }); } } } else { await sendSafeMessage(targetJid, foundCmd.content, msg); logs.push({ jid: targetJid, from: "MADOKA (Comando)", msg: foundCmd.content, date: timeStr, timestamp: Date.now(), type: 'out' }); } db.write(FILES.LOGS, logs.slice(-5000)); return; } if (!user.ia_active) return; try { const userHistory = logs.filter(l => l.jid === targetJid).slice(-12).map(l => `${l.from}: ${l.msg}`).join('\n'); const ctx = fs.readFileSync(FILES.CONTEXT, 'utf8'); const rules = db.read(FILES.RULES, []).map(r => `${r.topic}: ${r.content}`).join('\n'); const knowledgeBase = await getKnowledgeBase(); const settings = db.read(FILES.SETTINGS, DEFAULT_SETTINGS); let hasReceivedInfo = user.received_media && user.received_media.length > 0; // ⏰ INYECCIÓN DEL RELOJ INTERNO PARA SABER LA HORA EXACTA (PARA EL MENÚ DEL DÍA) const currentTimestampObj = new Date(); const timeStrSystem = currentTimestampObj.toLocaleTimeString('es-DO', { timeZone: 'America/Santo_Domingo', hour: '2-digit', minute: '2-digit', hour12: true }); // 🧠 INYECCIÓN DE REGLAS DE QUANTICO let hiddenInjection = ""; if (hasReceivedInfo) { hiddenInjection = `\n\n[INSTRUCCIÓN DEL SISTEMA: ETIQUETAS OBLIGATORIAS] Usa estas etiquetas ocultas (en una sola línea al final) según corresponda: - IMPORTANTE: YA ENVIASTE la información principal a este cliente. ESTÁ ESTRICTAMENTE PROHIBIDO volver a usar las etiquetas [SEND_DOC], [ATTACH] o [POST_MSG]. Dedícate a responder sus nuevas dudas de forma natural y cierra la venta. - Si el cliente completa los datos de su reserva (personas, fecha y hora) o pide que lo llamen: [ALERTA_RESERVA|cantidad, fecha, hora] [DEACTIVATE_AI] - Si el cliente pide hablar con soporte humano: [SOPORTE] [DEACTIVATE_AI] - SIEMPRE INCLUYE ESTAS AL FINAL: [FUNNEL|negotiating/pending/completed] [SENTIMENT|positive/neutral/negative] [SCORE|numero] [TAGS|etiqueta1,etiqueta2]`; } else { hiddenInjection = `\n\n[INSTRUCCIÓN DEL SISTEMA: ETIQUETAS OBLIGATORIAS] Usa estas etiquetas ocultas (en una sola línea al final) según corresponda: - Si el cliente completa los datos de su reserva (personas, fecha y hora): [ALERTA_RESERVA|cantidad, fecha, hora] [DEACTIVATE_AI] - Si el cliente pide hablar con soporte humano: [SOPORTE] [DEACTIVATE_AI] - Si ofreces menú o información general adjunta archivos así: [SEND_DOC|menu.txt] [ATTACH|fotos_menu] [POST_MSG|¿En qué te puedo ayudar hoy? 🎉] [DEACTIVATE_AI] - SIEMPRE INCLUYE ESTAS AL FINAL: [FUNNEL|new] [SENTIMENT|positive/neutral] [SCORE|numero] [TAGS|etiqueta1,etiqueta2]`; } const systemPrompt = `=== BASE DE CONOCIMIENTO TÉCNICO ===\nEmpresa: ${settings.companyName}\n⏰ HORA ACTUAL DEL SISTEMA: ${timeStrSystem}\n${knowledgeBase}\n\n=== HISTORIAL DE CHAT ===\n${userHistory}\n\n=== TU IDENTIDAD Y CONTEXTO ===\n${ctx}\n\n=== TUS REGLAS DE COMPORTAMIENTO (PRIORIDAD MÁXIMA) ===\n${rules}`; const promptSistemaCompleto = systemPrompt + hiddenInjection; const rawResponse = await consultarGroqBlindado([ { role: "system", content: promptSistemaCompleto }, { role: "user", content: textMessage } ]); let iaResponse = rawResponse.trim(); // Variables para alertas let esVenta = false; let pideSoporte = false; let datosReserva = null; let folderToAttach = null; let docToSend = null; let shouldDeactivate = false; let postMsgText = null; // Limpieza y extracción de etiquetas de Quantico if (iaResponse.match(/\[DEACTIVATE_AI\]/i)) { shouldDeactivate = true; iaResponse = iaResponse.replace(/\[DEACTIVATE_AI\]/gi, '').trim(); } if (iaResponse.match(/\[SOPORTE\]/i)) { pideSoporte = true; iaResponse = iaResponse.replace(/\[SOPORTE\]/gi, '').trim(); } let matchReserva = iaResponse.match(/\[ALERTA_RESERVA\|([^\]]+)\]/i); if (matchReserva) { datosReserva = matchReserva[1].trim(); iaResponse = iaResponse.replace(/\[ALERTA_RESERVA\|([^\]]+)\]/gi, '').trim(); } let matchPostMsg = iaResponse.match(/\[POST_MSG\|([^\]]+)\]/i); if (matchPostMsg) { postMsgText = matchPostMsg[1].trim(); iaResponse = iaResponse.replace(/\[POST_MSG\|([^\]]+)\]/gi, '').trim(); } if (iaResponse.match(/\[ALERTA_VENTA\]/i)) { esVenta = true; iaResponse = iaResponse.replace(/\[ALERTA_VENTA\]/gi, '').trim(); } let matchFunnel = iaResponse.match(/\[FUNNEL\|([^\]]+)\]/i); if (matchFunnel) { let stage = matchFunnel[1].trim().toLowerCase(); if(['new', 'negotiating', 'pending', 'completed'].includes(stage)) user.funnel_stage = stage; iaResponse = iaResponse.replace(/\[FUNNEL\|([^\]]+)\]/gi, '').trim(); } let matchSentiment = iaResponse.match(/\[SENTIMENT\|([^\]]+)\]/i); if (matchSentiment) { user.sentiment = matchSentiment[1].trim().toLowerCase(); iaResponse = iaResponse.replace(/\[SENTIMENT\|([^\]]+)\]/gi, '').trim(); } let matchScore = iaResponse.match(/\[SCORE\|([^\]]+)\]/i); if (matchScore) { user.score = parseInt(matchScore[1].trim()) || user.score; iaResponse = iaResponse.replace(/\[SCORE\|([^\]]+)\]/gi, '').trim(); } let matchTags = iaResponse.match(/\[TAGS\|([^\]]+)\]/i); if (matchTags) { user.tags = matchTags[1].split(',').map(t => t.trim().substring(0, 12)).slice(0, 2); iaResponse = iaResponse.replace(/\[TAGS\|([^\]]+)\]/gi, '').trim(); } let matchAttach = iaResponse.match(/\[ATTACH\|([^\]]+)\]/i); if (matchAttach) { folderToAttach = matchAttach[1].trim(); iaResponse = iaResponse.replace(/\[ATTACH\|([^\]]+)\]/gi, '').trim(); } let matchDoc = iaResponse.match(/\[SEND_DOC\|([^\]]+)\]/i); if (matchDoc) { docToSend = matchDoc[1].trim(); iaResponse = iaResponse.replace(/\[SEND_DOC\|([^\]]+)\]/gi, '').trim(); } // 🧠 ESCUDO ANTI-SPAM DEFINITIVO Y CANDADO DE APAGADO if (hasReceivedInfo) { folderToAttach = null; docToSend = null; postMsgText = null; } else if (folderToAttach || docToSend || postMsgText) { if (!user.received_media) user.received_media = []; user.received_media.push("info_sent"); } iaResponse = iaResponse.replace(/(\n\s*\d+\.\s*)*$/g, '').trim(); iaResponse = iaResponse.replace(/(\n\s*-\s*)*$/g, '').trim(); const currentUsers = db.read(FILES.USERS, []); const currentUserIndex = currentUsers.findIndex(u => u.id === targetJid); if(currentUserIndex > -1) { currentUsers[currentUserIndex] = user; } db.write(FILES.USERS, currentUsers); io.emit('user_updated', user); const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const isSalesFlow = docToSend || folderToAttach || postMsgText; // 1. Envía el texto principal - INMEDIATO (Si es saludo/flujo) o RETARDADO (Si es pregunta normal) if (iaResponse && iaResponse !== "") { if (!isSalesFlow && settings.delayGeneral > 0) { await sleep(settings.delayGeneral); } await sendSafeMessage(targetJid, iaResponse, msg); const logEntryOut = { jid: targetJid, from: "IA", msg: iaResponse, date: new Date().toLocaleTimeString('es-DO'), timestamp: Date.now(), type: 'out' }; logs.push(logEntryOut); db.write(FILES.LOGS, logs.slice(-5000)); io.emit('new_message_update', logEntryOut); } // 2. Espera X segundos antes de enviar la información y/o las fotos (configurable) if (docToSend || folderToAttach) { await sleep(settings.delayInfo); } // Envía el documento TXT if (docToSend) { try { const realFileName = findFileFuzzy(DOCS_DIR, docToSend); if (realFileName) { const docPath = path.join(DOCS_DIR, realFileName); const exactText = fs.readFileSync(docPath, 'utf8'); await sendSafeMessage(targetJid, exactText, msg); const logDoc = { jid: targetJid, from: "IA (Doc)", msg: `[Envió el archivo exacto: ${realFileName}]`, date: new Date().toLocaleTimeString('es-DO'), timestamp: Date.now(), type: 'out' }; const currentLogs = db.read(FILES.LOGS, []); currentLogs.push(logDoc); db.write(FILES.LOGS, currentLogs.slice(-5000)); io.emit('new_message_update', logDoc); } } catch (errDoc) { log(`❌ Error leyendo documento de texto: ${errDoc.message}`); } } // 3. Envía las fotos de la carpeta if (folderToAttach) { if (docToSend) await sleep(2000); try { const realFolderName = findFileFuzzy(MEDIA_DIR, folderToAttach); if (realFolderName) { const folderPath = path.join(MEDIA_DIR, realFolderName); const files = fs.readdirSync(folderPath); let sentCount = 0; for (let file of files) { if (file.match(/\.(png|jpe?g|gif|pdf|mp4)$/i)) { const filePath = path.join(folderPath, file); const mediaToSend = MessageMedia.fromFilePath(filePath); await sendSafeMedia(targetJid, mediaToSend, msg); sentCount++; await sleep(settings.delayMedia); } } if (sentCount > 0) { const logMedia = { jid: targetJid, from: "IA (Media)", msg: `[Envió imagen de '${realFolderName}']`, date: new Date().toLocaleTimeString('es-DO'), timestamp: Date.now(), type: 'out' }; const currentLogs2 = db.read(FILES.LOGS, []); currentLogs2.push(logMedia); db.write(FILES.LOGS, currentLogs2.slice(-5000)); io.emit('new_message_update', logMedia); } } } catch (errMedia) { log(`❌ Error crítico procesando carpeta de imágenes: ${errMedia.message}`); } } // 🧠 4. ENVÍA LA PREGUNTA DE CIERRE if (postMsgText) { await sleep(settings.delayClosing); await sendSafeMessage(targetJid, postMsgText, msg); const logPostMsg = { jid: targetJid, from: "IA (Seguimiento)", msg: postMsgText, date: new Date().toLocaleTimeString('es-DO'), timestamp: Date.now(), type: 'out' }; const currentLogs3 = db.read(FILES.LOGS, []); currentLogs3.push(logPostMsg); db.write(FILES.LOGS, currentLogs3.slice(-5000)); io.emit('new_message_update', logPostMsg); } // 🧠 5. ALERTAS ESPECIALIZADAS PARA QUANTICO (SOPORTE Y RESERVAS) if (esVenta || pideSoporte || datosReserva) { const allUsersForAlert = db.read(FILES.USERS, []); const admins = allUsersForAlert.filter(u => u.is_admin); for (let admin of admins) { try { let alertMessage = ""; if (datosReserva) { alertMessage = `🛎️ *NUEVA SOLICITUD DE RESERVA - QUANTICO* 🛎️\n\nEl cliente *${userName}* (+${targetJid.split('@')[0]}) ha solicitado una reserva con estos detalles:\n👉 *${datosReserva}*\n\n⚠️ La IA se ha desactivado. Entra y confirma la reserva con el cliente.`; } else if (pideSoporte) { alertMessage = `👨‍💻 *SOPORTE SOLICITADO* 👨‍💻\n\nEl cliente *${userName}* (+${targetJid.split('@')[0]}) ha pedido hablar con un humano.\n\n⚠️ Entra para atenderlo.`; } else if (esVenta) { alertMessage = `🚨 *ALERTA GENERAL* 🚨\n\nEl cliente *${userName}* (+${targetJid.split('@')[0]}) requiere atención humana.\n\n⚠️ La IA se ha desactivado para este chat.`; } await sendSafeMessage(admin.id, alertMessage, null); } catch(errAlert) { log(`❌ Error enviando alerta al admin ${admin.id}: ${errAlert.message}`); } } } // 🧠 APAGADO AUTOMÁTICO DE LA IA if (shouldDeactivate) { user.ia_active = false; const finalUsersUpdate2 = db.read(FILES.USERS, []); const fIdx2 = finalUsersUpdate2.findIndex(u => u.id === targetJid); if(fIdx2 > -1) finalUsersUpdate2[fIdx2] = user; db.write(FILES.USERS, finalUsersUpdate2); io.emit('user_updated', user); log(`⚠️ IA DESACTIVADA automáticamente para ${targetJid} tras requerir humano.`); } } catch (e) { log(`❌ Error General en IA [${targetJid}]: ${e.stack || e.message}`); if(e.message && e.message.includes('timeout')) { await sendSafeMessage(targetJid, `⏳ Procesando tu consulta, dame un segundito...`, msg); } } });