diff --git a/server.js b/server.js index 1789ec8..19fb6c1 100644 --- a/server.js +++ b/server.js @@ -9,6 +9,9 @@ import OpenAI from "openai"; const { Pool } = pg; const app = express(); +// 🛑 SEMÁFORO IA: Guarda los IDs de los servicios que están siendo procesados +const candadosIA = new Set(); + // Configuración de CORS Profesional const corsOptions = { origin: [ @@ -3004,22 +3007,23 @@ app.post("/webhook/evolution", async (req, res) => { const mensajeTexto = data.data.message?.conversation || data.data.message?.extendedTextMessage?.text; const instanceName = data.instance; - if (!mensajeTexto) return res.sendStatus(200); - - // Filtro de seguridad: Evitar procesar instancias del sistema como "ADMIN" - if (!instanceName || !instanceName.startsWith("cliente_")) { + if (!mensajeTexto || !instanceName || !instanceName.startsWith("cliente_")) { return res.sendStatus(200); } + // 🚀 CRÍTICO: Responder a Evolution rápido para que no reintente + res.sendStatus(200); + const ownerId = instanceName.split("_")[1]; const cleanPhone = telefonoCliente.slice(-9); - // 1. BUSCAMOS EL SINIESTRO (Extrayendo urgencia y población) - // 1. Añadimos s.assigned_to a la consulta const svcQ = await pool.query(` SELECT s.id, s.service_ref, s.assigned_to, u.full_name as worker_name, s.is_urgent, st.name as status_name, s.raw_data->>'scheduled_date' as cita, - s.raw_data->>'Población' as poblacion + s.raw_data->>'Población' as poblacion, + s.raw_data->>'appointment_status' as appointment_status, + s.raw_data->>'requested_date' as cita_pendiente_fecha, + s.raw_data->>'requested_time' as cita_pendiente_hora FROM scraped_services s LEFT JOIN users u ON s.assigned_to = u.id LEFT JOIN service_statuses st ON (s.raw_data->>'status_operativo')::text = st.id::text @@ -3030,86 +3034,71 @@ app.post("/webhook/evolution", async (req, res) => { if (svcQ.rowCount > 0) { const service = svcQ.rows[0]; - // 🛡️ 2. VERIFICAR INTERVENCIÓN HUMANA - // Miramos si el último mensaje fue de un humano en las últimas 2 horas - const checkHumanQ = await pool.query(` - SELECT sender_role, created_at - FROM service_communications - WHERE scraped_id = $1 - ORDER BY created_at DESC LIMIT 1 - `, [service.id]); - - if (checkHumanQ.rowCount > 0) { - const lastMsg = checkHumanQ.rows[0]; - const diffMinutos = (new Date() - new Date(lastMsg.created_at)) / (1000 * 60); - - // Si el último que habló fue admin/operario y hace menos de 120 min, la IA no responde - if (['admin', 'superadmin', 'operario'].includes(lastMsg.sender_role) && diffMinutos < 120) { - console.log(`🤫 [IA Silenciada] Un humano ha intervenido hace ${Math.round(diffMinutos)} min en el exp #${service.service_ref}`); - return res.sendStatus(200); - } + // 🛑 SEMÁFORO ANTI-METRALLETA: Si ya estamos procesando este aviso, abortamos. + if (candadosIA.has(service.id)) { + console.log(`⏳ [Bloqueo] Ignorando mensaje rápido concurrente para exp #${service.service_ref}`); + return; } + // Cerramos el candado + candadosIA.add(service.id); - // 3. SI NO HAY HUMANO RECIENTE, LLAMAMOS A LA IA CON TODO EL CONTEXTO - const respuestaIA = await procesarConIA(ownerId, mensajeTexto, { - dbId: service.id, // 👈 CRÍTICO para que la IA lea el historial - ref: service.service_ref, - estado: service.status_name || "En proceso", - operario: service.worker_name, - cita: service.cita, - worker_id: service.assigned_to, - poblacion: service.poblacion || "", // 👈 CRÍTICO para buscar rutas en la misma zona - is_urgent: service.is_urgent, // 👈 CRÍTICO para que no pida cita en urgencias - appointment_status: service.appointment_status, - cita_pendiente_fecha: service.cita_pendiente_fecha, - cita_pendiente_hora: service.cita_pendiente_hora - }); + try { + // 🛡️ VERIFICAR INTERVENCIÓN HUMANA + const checkHumanQ = await pool.query(` + SELECT sender_role, created_at FROM service_communications + WHERE scraped_id = $1 ORDER BY created_at DESC LIMIT 1 + `, [service.id]); - if (respuestaIA) { - // --- MAGIA: DETECTAR SI LA IA RECOGIÓ UNA PROPUESTA DE CITA --- - const matchPropuesta = respuestaIA.match(/\[PROPUESTA:(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})\]/); - - if (matchPropuesta) { - const fechaSugerida = matchPropuesta[1]; - const horaSugerida = matchPropuesta[2]; - - console.log(`📅 PROPUESTA RECIBIDA (IA): ${fechaSugerida} a las ${horaSugerida} para exp #${service.service_ref}`); - - // 🚀 Guardar como "requested_date" para que aparezca en el Panel en "Citas Solicitadas" - await pool.query(` - UPDATE scraped_services - SET raw_data = raw_data || jsonb_build_object( - 'requested_date', $1::text, - 'requested_time', $2::text, - 'appointment_status', 'pending' - ) - WHERE id = $3 - `, [fechaSugerida, horaSugerida, service.id]); - - // Registramos el movimiento en el historial - await registrarMovimiento( - service.id, - null, - "Cita Solicitada (IA)", - `El cliente solicita cita vía Asistente IA para el ${fechaSugerida} a las ${horaSugerida}.` - ); + if (checkHumanQ.rowCount > 0) { + const lastMsg = checkHumanQ.rows[0]; + const diffMinutos = (new Date() - new Date(lastMsg.created_at)) / (1000 * 60); + if (['admin', 'superadmin', 'operario'].includes(lastMsg.sender_role) && diffMinutos < 120) { + return; // IA Silenciada + } } - // --- LIMPIEZA Y ENVÍO --- - // Quitamos el código [PROPUESTA:...] del texto para que el cliente no lo vea - const textoLimpio = respuestaIA.replace(/\[PROPUESTA:.*?\]/, "").trim(); + // 🧠 LLAMADA A LA IA + const respuestaIA = await procesarConIA(ownerId, mensajeTexto, { + dbId: service.id, + ref: service.service_ref, + estado: service.status_name || "En proceso", + operario: service.worker_name, + worker_id: service.assigned_to, + cita: service.cita, + poblacion: service.poblacion || "", + is_urgent: service.is_urgent, + appointment_status: service.appointment_status, + cita_pendiente_fecha: service.cita_pendiente_fecha, + cita_pendiente_hora: service.cita_pendiente_hora + }); - await sendWhatsAppAuto(telefonoCliente, textoLimpio, instanceName, true); + if (respuestaIA) { + const matchPropuesta = respuestaIA.match(/\[PROPUESTA:(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})\]/); + if (matchPropuesta) { + const fechaSugerida = matchPropuesta[1]; + const horaSugerida = matchPropuesta[2]; + await pool.query(` + UPDATE scraped_services + SET raw_data = raw_data || jsonb_build_object( + 'requested_date', $1::text, + 'requested_time', $2::text, + 'appointment_status', 'pending' + ) WHERE id = $3 + `, [fechaSugerida, horaSugerida, service.id]); + } - // --- REGISTRO DEL CHAT --- - await pool.query(`INSERT INTO service_communications (scraped_id, owner_id, sender_name, sender_role, message) VALUES ($1, $2, $3, $4, $5)`, - [service.id, ownerId, "Asistente IA", "ia", textoLimpio]); + const textoLimpio = respuestaIA.replace(/\[PROPUESTA:.*?\]/, "").trim(); + await sendWhatsAppAuto(telefonoCliente, textoLimpio, instanceName, true); + await pool.query(`INSERT INTO service_communications (scraped_id, owner_id, sender_name, sender_role, message) VALUES ($1, $2, $3, $4, $5)`, + [service.id, ownerId, "Asistente IA", "ia", textoLimpio]); + } + } finally { + // 🟢 ABRIMOS EL CANDADO SIEMPRE AL TERMINAR (Aunque haya error) + candadosIA.delete(service.id); } } - res.sendStatus(200); } catch (e) { console.error("❌ [WEBHOOK ERROR]:", e.message); - if (!res.headersSent) res.sendStatus(500); } });