diff --git a/server.js b/server.js index 29c62b2..12f55bb 100644 --- a/server.js +++ b/server.js @@ -908,71 +908,99 @@ async function registrarMovimiento(serviceId, userId, action, details) { // ========================================== async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { try { - const userQ = await pool.query("SELECT wa_settings, full_name, portal_settings FROM users WHERE id=$1", [ownerId]); + const userQ = await pool.query( + "SELECT wa_settings, full_name, portal_settings FROM users WHERE id=$1", + [ownerId] + ); + const userData = userQ.rows[0]; const settings = userData?.wa_settings || {}; const instruccionesExtra = settings.ai_custom_prompt || ""; const empresaNombre = userData?.full_name || "nuestra empresa"; - console.log("🕵️ CHIVATO PROMPT EXTRA:", instruccionesExtra); - - const pSettings = userData?.portal_settings || {}; - const horarios = { m_start: pSettings.m_start || "09:00", m_end: pSettings.m_end || "14:00", a_start: pSettings.a_start || "16:00", a_end: pSettings.a_end || "19:00" }; - if (!settings.wa_ai_enabled) return null; - const ahora = new Date(); - const fechaHoyTexto = ahora.toLocaleDateString('es-ES', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); + const pSettings = userData?.portal_settings || {}; + const horarios = { + m_start: pSettings.m_start || "09:00", + m_end: pSettings.m_end || "14:00", + a_start: pSettings.a_start || "16:00", + a_end: pSettings.a_end || "19:00" + }; - const historyQ = await pool.query("SELECT sender_role, message FROM service_communications WHERE scraped_id = $1 ORDER BY created_at DESC LIMIT 8", [datosExpediente.dbId]); - const historialChat = historyQ.rows.reverse().map(row => ({ - role: (row.sender_role === 'ia' || row.sender_role === 'admin' || row.sender_role === 'operario') ? 'assistant' : 'user', + const fechaHoyTexto = new Intl.DateTimeFormat("es-ES", { + timeZone: "Europe/Madrid", + weekday: "long", + year: "numeric", + month: "long", + day: "numeric" + }).format(new Date()); + + // 👇 IMPORTANTE: + // Aquí leemos el historial REAL, ya incluyendo el mensaje actual del cliente + // que el webhook guardará antes de llamar a OpenAI. + const historyQ = await pool.query(` + SELECT sender_role, message + FROM service_communications + WHERE scraped_id = $1 + AND sender_role <> 'system' + ORDER BY created_at DESC + LIMIT 12 + `, [datosExpediente.dbId]); + + const historialRows = historyQ.rows.reverse(); + + const historialChat = historialRows.map(row => ({ + role: row.sender_role === 'user' ? 'user' : 'assistant', content: row.message })); - const esPrimerMensaje = historialChat.length === 0; - let agendaOcupadaTexto = "El técnico tiene la agenda libre en horario laboral."; + // Si solo existe el mensaje actual del cliente, es primer mensaje + const esPrimerMensaje = historialRows.length <= 1; + + let agendaOcupadaTexto = "✅ El técnico tiene la agenda libre en horario laboral."; + if (datosExpediente.worker_id) { - // 🛑 AÑADIDO: Consulta avanzada que lee Duraciones, Bloqueos y Citas Pendientes const agendaQ = await pool.query(` SELECT COALESCE(NULLIF(raw_data->>'scheduled_date', ''), raw_data->>'requested_date') as date, COALESCE(NULLIF(raw_data->>'scheduled_time', ''), raw_data->>'requested_time') as time, raw_data->>'duration_minutes' as duration, - raw_data->>'Población' as pob, + COALESCE(raw_data->>'Población', raw_data->>'POBLACION-PROVINCIA') as pob, provider FROM scraped_services - WHERE assigned_to = $1 - AND status != 'archived' - AND id != $2 - AND ( + WHERE owner_id = $1 + AND assigned_to = $2 + AND status != 'archived' + AND id != $3 + AND ( (raw_data->>'scheduled_date' IS NOT NULL AND raw_data->>'scheduled_date' >= CURRENT_DATE::text) OR (raw_data->>'appointment_status' = 'pending' AND raw_data->>'requested_date' >= CURRENT_DATE::text) - ) + ) ORDER BY date ASC, time ASC - `, [datosExpediente.worker_id, datosExpediente.dbId]); + `, [ownerId, datosExpediente.worker_id, datosExpediente.dbId]); if (agendaQ.rowCount > 0) { const ocupaciones = {}; - agendaQ.rows.forEach(r => { - if (r.date && r.time && r.time.includes(':')) { - if (!ocupaciones[r.date]) ocupaciones[r.date] = []; - - // Calculamos la hora de fin exacta sumando la duración (Ej: 60, 120, 180 min) + + agendaQ.rows.forEach(r => { + if (r.date && r.time && r.time.includes(':')) { + if (!ocupaciones[r.date]) ocupaciones[r.date] = []; + let [h, m] = r.time.split(':').map(Number); let dur = parseInt(r.duration || 60, 10); let endMin = (h * 60 + m) + dur; let endH = String(Math.floor(endMin / 60) % 24).padStart(2, '0'); let endM = String(endMin % 60).padStart(2, '0'); - - let tipo = r.provider === 'SYSTEM_BLOCK' ? 'BLOQUEO/AUSENCIA' : 'CITA'; - let lugar = r.pob || 'Otra zona'; - - ocupaciones[r.date].push(`De ${r.time} a ${endH}:${endM} (${tipo} en ${lugar})`); - } + + const tipo = r.provider === 'SYSTEM_BLOCK' ? 'BLOQUEO/AUSENCIA' : 'CITA'; + const lugar = r.pob || 'Otra zona'; + + ocupaciones[r.date].push(`❌ OCUPADO de ${r.time} a ${endH}:${endM} (${tipo} en ${lugar})`); + } }); - + const lineas = Object.keys(ocupaciones).sort().map(d => { const [y, m, day] = d.split('-').map(Number); const fechaHumana = new Date(y, m - 1, day, 12, 0, 0).toLocaleDateString('es-ES', { @@ -984,9 +1012,11 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { }); if (lineas.length > 0) { - agendaOcupadaTexto = "Ocupaciones actuales del técnico (Citas confirmadas, pendientes y bloqueos):\n" + lineas.join("\n") + - "\n\n👉 IMPORTANTE: Todas las horas que NO se solapen con esos tramos exactos ESTÁN LIBRES." + - "\n🚨 REGLA LOGÍSTICA ESTRICTA: El técnico necesita tiempo para viajar. Si el cliente actual es de una localidad distinta a la cita anterior o posterior (ej: Algeciras vs La Línea), ES OBLIGATORIO dejar un margen de al menos 45-60 minutos de viaje entre el final de una cita y el inicio de la siguiente. NUNCA ofrezcas horas pegadas si hay desplazamiento."; + agendaOcupadaTexto = + "⚠️ ¡ATENCIÓN! LA SIGUIENTE LISTA SON LOS HORARIOS QUE YA ESTÁN OCUPADOS Y NO PUEDES OFRECER:\n" + + lineas.join("\n") + + "\n\n👉 INSTRUCCIÓN VITAL: Revisa bien la lista de arriba. Tienes que proponerle al cliente CUALQUIER OTRA HORA que esté totalmente libre y no pise esos tramos." + + "\n🚨 REGLA LOGÍSTICA: Si la zona de la avería actual no es la misma que la de la cita anterior o posterior, DEBES dejar al menos 45-60 minutos libres para el viaje."; } } } @@ -995,7 +1025,6 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { const tieneCitaConfirmada = datosExpediente.cita && datosExpediente.cita !== 'Ninguna'; const esUrgencia = datosExpediente.is_urgent; - // 🕐 CONVERTIMOS LA HORA PENDIENTE EN TRAMO DE 1 HORA let tramoPendiente = datosExpediente.cita_pendiente_hora || ""; if (tramoPendiente && tramoPendiente.includes(":")) { let [h, m] = tramoPendiente.split(':'); @@ -1003,7 +1032,6 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { tramoPendiente = `entre las ${h}:${m} y las ${hEnd}:${m} aprox`; } - // 🕐 CONVERTIMOS LA HORA CONFIRMADA EN TRAMO DE 1 HORA let tramoConfirmado = datosExpediente.hora_cita || ""; if (tramoConfirmado && tramoConfirmado.includes(":")) { let [h, m] = tramoConfirmado.split(':'); @@ -1013,23 +1041,24 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { tramoConfirmado = 'una hora por confirmar'; } - // 🛑 AÑADIDO: MÁQUINA DEL TIEMPO (Saber si la cita ya pasó) let citaYaPaso = false; if (tieneCitaConfirmada && datosExpediente.cita) { - const hoyTime = new Date().setHours(0,0,0,0); + const hoyTime = new Date().setHours(0, 0, 0, 0); const [y, m, d] = datosExpediente.cita.split('-'); - const citaTime = new Date(y, m - 1, d).setHours(0,0,0,0); + const citaTime = new Date(y, m - 1, d).setHours(0, 0, 0, 0); if (citaTime < hoyTime) citaYaPaso = true; } - // 🛑 DETECTORES DE ESTADO - const esEstadoFinal = datosExpediente.estado && (datosExpediente.estado.toLowerCase().includes('finalizado') || datosExpediente.estado.toLowerCase().includes('terminado') || datosExpediente.estado.toLowerCase().includes('anulado')); - const nombreCia = datosExpediente.compania || "su Aseguradora"; - const esSeguro = !nombreCia.toLowerCase().includes('particular'); + const esEstadoFinal = datosExpediente.estado && ( + datosExpediente.estado.toLowerCase().includes('finalizado') || + datosExpediente.estado.toLowerCase().includes('terminado') || + datosExpediente.estado.toLowerCase().includes('anulado') + ); + const noTieneTecnico = !datosExpediente.worker_id; let directivaEstricta = ""; - + if (esEstadoFinal) { directivaEstricta = `🛑 ESTADO ACTUAL: SERVICIO CERRADO. Informa al cliente que el servicio por su avería (${datosExpediente.averia}) está finalizado. NO AGENDES NADA.`; } else if (noTieneTecnico) { @@ -1047,42 +1076,48 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { } const promptSistema = ` - Eres el coordinador humano de "${empresaNombre}". Hablas de tú, de forma muy natural, empática y al con un buen sentido de humor por WhatsApp. +Eres el coordinador humano de "${empresaNombre}". Hablas de tú, de forma muy natural, empática y con buen tono por WhatsApp. - --- 📋 CONTEXTO BÁSICO --- - - Hoy es: ${fechaHoyTexto}. (Año 2026). - - Horario de la empresa: L-V de ${horarios.m_start} a ${horarios.m_end} y de ${horarios.a_start} a ${horarios.a_end}. Fines de semana solo URGENCIAS. - - ⛔ REGLA DE ORO DEL HORARIO: Está ABSOLUTAMENTE PROHIBIDO agendar o proponer citas fuera del horario de la empresa. El tramo entre las ${horarios.m_end} y las ${horarios.a_start} es para COMER y DESCANSAR. NUNCA propongas horas que pisen ese tramo. - - Localidad del cliente actual: ${datosExpediente.poblacion || 'Localidad no especificada'}. +--- 📋 CONTEXTO BÁSICO --- +- Hoy es: ${fechaHoyTexto}. +- Horario de la empresa: L-V de ${horarios.m_start} a ${horarios.m_end} y de ${horarios.a_start} a ${horarios.a_end}. +- ⛔ REGLA DE ORO DEL HORARIO: NUNCA propongas horas fuera de ese horario ni pises el tramo de comer. Fines de semana prohibidos salvo urgencias. +- Localidad del cliente actual: ${datosExpediente.poblacion || 'Localidad no especificada'}. - --- 📅 AGENDA DEL TÉCNICO ASIGNADO --- - ${agendaOcupadaTexto} +--- 📅 REVISIÓN DE AGENDA (EVITAR SOLAPES) --- +${agendaOcupadaTexto} - --- 🎯 DIRECTIVA ESTRICTA PARA ESTE MENSAJE --- - ${directivaEstricta} +--- 🎯 DIRECTIVA ESTRICTA PARA ESTE MENSAJE --- +${directivaEstricta} - --- ⚡ REGLA CRÍTICA DE AGENDA (COMANDO SECRETO) --- - Si (y solo si) has propuesto un hueco y el cliente ACEPTA FIRMEMENTE, DEBES añadir AL FINAL ABSOLUTO de tu respuesta este texto literal: - [PROPUESTA:YYYY-MM-DD HH:mm] - Ejemplo de respuesta tuya: "Perfecto, le paso la nota al técnico para que te confirme el miércoles entre las 10:00 y las 11:00 aprox. ¡Te decimos algo pronto! [PROPUESTA:2026-03-25 10:00]" - ⛔ PROHIBICIÓN: NUNCA le digas "te agendo" ni "cita confirmada". El cliente debe saber que dependemos del técnico. NUNCA menciones las palabras "código" o "etiqueta". +--- ⚡ REGLA CRÍTICA DE AGENDA (COMANDO SECRETO) --- +Si (y solo si) has propuesto un hueco y el cliente ACEPTA FIRMEMENTE, DEBES añadir AL FINAL ABSOLUTO de tu respuesta este texto literal: +[PROPUESTA:YYYY-MM-DD HH:mm] - --- ⚙️ REGLAS DE COMUNICACIÓN --- - 1. MÁXIMO 2 FRASES. Mensajes cortos y directos. - 2. NUNCA uses fechas frías (Usa "el martes"). NUNCA des una hora exacta (Usa "entre las 10:00 y las 11:00 aprox"). - 3. NO TE PRESENTES si ya habéis intercambiado mensajes. - 4. ⛔ MULETILLAS PROHIBIDAS: NUNCA digas "¿En qué más te puedo ayudar?". Da la información y pon un punto. - ${esPrimerMensaje ? '5. Primer mensaje: preséntate y menciona el aviso (#' + datosExpediente.ref + ').' : ''} - ${instruccionesExtra ? '6. Instrucción extra de la empresa: ' + instruccionesExtra : ''} +Ejemplo: +"Perfecto, le paso la nota al técnico para que te confirme el miércoles entre las 10:00 y las 11:00 aprox. ¡Te decimos algo pronto! [PROPUESTA:2026-03-25 10:00]" + +⛔ PROHIBICIÓN: NUNCA le digas "te agendo" ni "cita confirmada". El cliente debe saber que dependemos del técnico. NUNCA menciones las palabras "código" o "etiqueta". + +--- ⚙️ REGLAS DE COMUNICACIÓN --- +1. MÁXIMO 2 FRASES. Mensajes cortos y directos. +2. NUNCA uses fechas frías si puedes decir "el martes". NUNCA des una hora exacta si puedes decir "entre las 10:00 y las 11:00 aprox". +3. NO TE PRESENTES si ya habéis intercambiado mensajes. +4. ⛔ MULETILLAS PROHIBIDAS: NUNCA digas "¿En qué más te puedo ayudar?". +${esPrimerMensaje ? `5. Primer mensaje: preséntate y menciona el aviso (#${datosExpediente.ref}).` : ''} +${instruccionesExtra ? `6. Instrucción extra de la empresa: ${instruccionesExtra}` : ''} `; const completion = await openai.chat.completions.create({ - model: "gpt-4o-mini", - messages: [{ role: "system", content: promptSistema }, ...historialChat, { role: "user", content: mensajeCliente }], - temperature: 0.1, + model: OPENAI_MODEL || "gpt-4o-mini", + messages: [ + { role: "system", content: promptSistema }, + ...historialChat + ], + temperature: 0.1 }); - return completion.choices[0].message.content; + return completion.choices?.[0]?.message?.content || null; } catch (e) { console.error("❌ Error OpenAI:", e.message); return null; @@ -4046,245 +4081,278 @@ app.get("/services/:id/chat", authMiddleware, async (req, res) => { } }); -// 2. Enviar un nuevo mensaje (Oficina u Operario) CON AVISO WHATSAPP Y TRAZABILIDAD -app.post("/services/:id/chat", authMiddleware, async (req, res) => { - try { - const { id } = req.params; - const { message, is_internal } = req.body; - - if (!message || message.trim() === "") return res.status(400).json({ ok: false, error: "Vacío" }); - - const isOperario = req.user.role === 'operario'; - const finalIsInternal = isOperario ? false : (is_internal || false); - - const userQ = await pool.query("SELECT full_name, role FROM users WHERE id=$1", [req.user.sub]); - const senderName = userQ.rows[0]?.full_name || "Usuario"; - const senderRole = userQ.rows[0]?.role || "operario"; - - // 1. Guardar el mensaje en la base de datos (Chat) - await pool.query(` - INSERT INTO service_communications - (scraped_id, owner_id, sender_id, sender_name, sender_role, message, is_internal) - VALUES ($1, $2, $3, $4, $5, $6, $7) - `, [id, req.user.accountId, req.user.sub, senderName, senderRole, message.trim(), finalIsInternal]); - - res.json({ ok: true }); - - // 2. Lógica de Notificación y Trazabilidad - if (!isOperario && !finalIsInternal) { - const svcQ = await pool.query("SELECT assigned_to, service_ref FROM scraped_services WHERE id=$1", [id]); - - if (svcQ.rowCount > 0 && svcQ.rows[0].assigned_to) { - const workerId = svcQ.rows[0].assigned_to; - const ref = svcQ.rows[0].service_ref || id; - - const wQ = await pool.query("SELECT phone, full_name FROM users WHERE id=$1", [workerId]); - - if (wQ.rowCount > 0 && wQ.rows[0].phone) { - const workerPhone = wQ.rows[0].phone; - const workerName = wQ.rows[0].full_name; - - const msgWa = `💬 *NUEVO MENSAJE DE LA OFICINA*\nExpediente: *#${ref}*\n\n"${message.trim()}"\n\n_Entra en tu App para contestar._`; - - // A) Disparar el WhatsApp - const waExito = await sendWhatsAppAuto(workerPhone, msgWa, `cliente_${req.user.accountId}`, false); - - // B) 🟢 DEJAR HUELLA EN EL LOG (Trazabilidad) - const logDetalle = waExito - ? `Aviso enviado por WhatsApp a ${workerName} (${workerPhone}).` - : `Intento de aviso por WhatsApp a ${workerName} fallido (revisar conexión Evolution).`; - - await pool.query( - "INSERT INTO scraped_service_logs (scraped_id, user_name, action, details) VALUES ($1, $2, $3, $4)", - [id, "Sistema (Chat)", "Notificación Enviada", logDetalle] - ); - } - } - } - - } catch (e) { - console.error("Error enviando mensaje y log:", e); - if (!res.headersSent) res.status(500).json({ ok: false }); - } -}); - // 🤖 WEBHOOK CON ESCUDO HUMANO, MEMORIA Y GESTIÓN DE URGENCIAS app.post("/webhook/evolution", async (req, res) => { try { const data = req.body; - // 🚨 CAMBIO 1: Quitamos el 'fromMe' de aquí arriba para que el servidor escuche TUS mensajes - if (data.event !== "messages.upsert") return res.sendStatus(200); - const remoteJid = data.data.key.remoteJid; - const telefonoCliente = remoteJid.split("@")[0]; - const mensajeTexto = data.data.message?.conversation || data.data.message?.extendedTextMessage?.text; - const instanceName = data.instance; - - if (!mensajeTexto || !instanceName || !instanceName.startsWith("cliente_")) { + if (data.event !== "messages.upsert") { return res.sendStatus(200); } - // 🚀 CRÍTICO: Responder a Evolution rápido para que no reintente + const remoteJid = data?.data?.key?.remoteJid || ""; + const fromMe = !!data?.data?.key?.fromMe; + const instanceName = data?.instance || ""; + const mensajeTexto = ( + data?.data?.message?.conversation || + data?.data?.message?.extendedTextMessage?.text || + "" + ).trim(); + + if (!mensajeTexto || !remoteJid || !instanceName) { + return res.sendStatus(200); + } + + // Respondemos rápido a Evolution res.sendStatus(200); - const ownerId = instanceName.split("_")[1]; - const cleanPhone = telefonoCliente.slice(-9); - - // 🔍 BUSCAMOS EL EXPEDIENTE ACTIVO MÁS RECIENTE + // Tu arquitectura actual depende de cliente_ID + if (!/^cliente_\d+$/.test(instanceName)) { + console.log(`⚠️ [WEBHOOK IA] Instancia ignorada: ${instanceName}`); + return; + } + + const ownerId = parseInt(instanceName.replace("cliente_", ""), 10); + if (!ownerId) return; + + const telefonoCliente = remoteJid.split("@")[0]; + const cleanPhone = telefonoCliente.replace(/\D/g, "").slice(-9); + if (!cleanPhone || cleanPhone.length < 9) return; + + // 🔍 BUSCAMOS EL EXPEDIENTE ACTIVO MÁS RECIENTE POR TELÉFONO REAL 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->>'scheduled_time' as hora_cita, - 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, - s.raw_data->>'Compañía' as compania, - COALESCE(s.raw_data->>'Descripción', s.raw_data->>'DESCRIPCION') as averia, - s.raw_data->>'ia_paused' as ia_paused -- 🚨 BÚSQUEDA DEL SEMÁFORO ROJO + SELECT + s.id, + s.service_ref, + s.assigned_to, + s.is_urgent, + u.full_name as worker_name, + st.name as status_name, + COALESCE(s.raw_data->>'scheduled_date', '') as cita, + COALESCE(s.raw_data->>'scheduled_time', '') as hora_cita, + COALESCE(s.raw_data->>'Población', s.raw_data->>'POBLACION-PROVINCIA', '') as poblacion, + COALESCE(s.raw_data->>'appointment_status', '') as appointment_status, + COALESCE(s.raw_data->>'requested_date', '') as cita_pendiente_fecha, + COALESCE(s.raw_data->>'requested_time', '') as cita_pendiente_hora, + COALESCE(s.raw_data->>'Compañía', s.raw_data->>'COMPAÑIA', '') as compania, + COALESCE(s.raw_data->>'Descripción', s.raw_data->>'DESCRIPCION', '') as averia, + COALESCE((s.raw_data->>'ia_paused')::boolean, false) as ia_paused 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 - WHERE s.owner_id = $1 - AND s.status != 'archived' - AND s.raw_data::text ILIKE $2 - AND (st.name IS NULL OR (st.name NOT ILIKE '%finalizado%' AND st.name NOT ILIKE '%anulado%' AND st.name NOT ILIKE '%desasignado%')) - ORDER BY s.created_at DESC LIMIT 1 - `, [ownerId, `%${cleanPhone}%`]); + WHERE s.owner_id = $1 + AND s.status != 'archived' + AND RIGHT( + REGEXP_REPLACE( + COALESCE( + s.raw_data->>'Teléfono', + s.raw_data->>'TELEFONO', + s.raw_data->>'TELEFONOS', + '' + ), + '\\D', + '', + 'g' + ), + 9 + ) = $2 + AND ( + st.name IS NULL OR ( + st.name NOT ILIKE '%finalizado%' AND + st.name NOT ILIKE '%anulado%' AND + st.name NOT ILIKE '%desasignado%' + ) + ) + ORDER BY + (SELECT MAX(sc.created_at) FROM service_communications sc WHERE sc.scraped_id = s.id) DESC NULLS LAST, + s.created_at DESC + LIMIT 1 + `, [ownerId, cleanPhone]); - if (svcQ.rowCount > 0) { - const service = svcQ.rows[0]; - - if (data.data.key.fromMe) { - const msgCmd = mensajeTexto.trim(); - - // 🔴 COMANDO MÁGICO 1: PAUSAR LA IA (Resistente a emojis de iPhone) - if (msgCmd.includes('🔴')) { - try { - await pool.query(`UPDATE scraped_services SET raw_data = COALESCE(raw_data, '{}'::jsonb) || '{"ia_paused": true}'::jsonb WHERE id = $1`, [service.id]); - // 🛑 Guardamos como 'system' para que NO active el escudo humano de 2 horas - await pool.query(`INSERT INTO service_communications (scraped_id, owner_id, sender_name, sender_role, message, is_internal) VALUES ($1, $2, $3, $4, $5, true)`, [service.id, ownerId, "Sistema", "system", "🔴 IA Pausada manualmente con Emoji."]); - console.log(`🔴 [IA PAUSADA] Semáforo rojo activado para exp ${service.service_ref}`); - } catch(err) { console.error("Error pausando IA:", err); } - return; - } - - // 🟢 COMANDO MÁGICO 2: ACTIVAR LA IA - if (msgCmd.includes('🟢')) { - try { - await pool.query(`UPDATE scraped_services SET raw_data = raw_data - 'ia_paused' WHERE id = $1`, [service.id]); - // 🟢 Guardamos como 'system' para EVITAR que la IA se autobloquee al despertarla - await pool.query(`INSERT INTO service_communications (scraped_id, owner_id, sender_name, sender_role, message, is_internal) VALUES ($1, $2, $3, $4, $5, true)`, [service.id, ownerId, "Sistema", "system", "🟢 IA Reactivada manualmente con Emoji."]); - console.log(`🟢 [IA ACTIVADA] Semáforo verde activado para exp ${service.service_ref}`); - } catch(err) { console.error("Error activando IA:", err); } - return; - } - - // Guardado normal si es un texto tuyo hablando con el cliente - try { - 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, "Técnico (WhatsApp)", "operario", mensajeTexto]); - } catch(err) { console.error("Error guardando mensaje de admin:", err); } - return; - } - - if (candadosIA.has(service.id)) return; - candadosIA.add(service.id); - - try { - // 🛑 COMPROBAR SI LA HEMOS PAUSADO CON EL SEMÁFORO ROJO - if (service.ia_paused === 'true' || service.ia_paused === true) { - console.log(`🤫 [IA MUTEADA] El cliente ha hablado, pero la IA está en semáforo rojo para ${service.service_ref}`); - return; - } - - // 🛡️ VERIFICAR INTERVENCIÓN HUMANA (ESCUDO INTELIGENTE) - // Buscamos el último mensaje, PERO ignoramos al 'system' y al 'user' - const checkHumanQ = await pool.query(` - SELECT sender_role, created_at FROM service_communications - WHERE scraped_id = $1 AND sender_role NOT IN ('user', 'system') - 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); - - // 🛑 ESCUDO ACTIVADO: 120 minutos de silencio desde tu último mensaje real - if (['admin', 'superadmin', 'operario'].includes(lastMsg.sender_role) && diffMinutos < 120) { - console.log(`🛡️ [ESCUDO IA] Silenciando a la IA porque un humano habló hace ${Math.round(diffMinutos)} minutos.`); - return; - } - } - - // 🧠 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, - hora_cita: service.hora_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, - compania: service.compania, - averia: service.averia - }); - - if (respuestaIA) { - // 🛡️ REGEX BLINDADO: Pilla la etiqueta aunque la IA meta espacios raros - const matchPropuesta = respuestaIA.match(/\[PROPUESTA:\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\]/i); - - // 🧹 BORRAMOS EL TEXTO DEL CÓDIGO PARA QUE EL CLIENTE NO LO VEA NUNCA - let textoLimpio = respuestaIA.replace(/\[PROPUESTA:.*?\]/gi, "").replace(/código:/gi, "").trim(); - - if (matchPropuesta) { - const fechaSugerida = matchPropuesta[1]; - const horaSugerida = matchPropuesta[2]; - - // 🛡️ ESCUDO ANTI-SOLAPE: comprobamos antes de guardar la propuesta - const disponibilidad = await comprobarDisponibilidad( - ownerId, - service.assigned_to, - fechaSugerida, - horaSugerida, - 60, - service.id - ); - - if (disponibilidad.choca) { - console.log(`⛔ [DOBLE-BOOKING EVITADO] Exp ${service.service_ref} chocaba con ${disponibilidad.ref} a las ${disponibilidad.time}`); - textoLimpio = "Uy, perdona, se me acaban de cruzar los cables y justo me han bloqueado ese hueco por el sistema interno. 😅 ¿Me dices otra hora que te venga bien?"; - } else { - // 🚀 GUARDADO COMO PENDIENTE (Espera a que el Técnico la apruebe en la App o en la Oficina) - 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]); - } - } - - 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 { - candadosIA.delete(service.id); - } + if (svcQ.rowCount === 0) { + console.log(`⚠️ [WEBHOOK IA] No se encontró expediente activo para ${cleanPhone}`); + return; } + + const service = svcQ.rows[0]; + + // ========================================== + // 🧑‍🔧 MENSAJES TUYOS / ADMIN / OFICINA + // ========================================== + if (fromMe) { + const msgCmd = mensajeTexto.trim(); + + // 🔴 PAUSAR IA + if (msgCmd.includes("🔴")) { + try { + await pool.query(` + UPDATE scraped_services + SET raw_data = COALESCE(raw_data, '{}'::jsonb) || '{"ia_paused": true}'::jsonb + WHERE id = $1 + `, [service.id]); + + await pool.query(` + INSERT INTO service_communications + (scraped_id, owner_id, sender_name, sender_role, message, is_internal) + VALUES ($1, $2, 'Sistema', 'system', $3, true) + `, [service.id, ownerId, "🔴 IA Pausada manualmente con Emoji."]); + + console.log(`🔴 [IA PAUSADA] ${service.service_ref}`); + } catch (err) { + console.error("Error pausando IA:", err); + } + return; + } + + // 🟢 REACTIVAR IA + if (msgCmd.includes("🟢")) { + try { + await pool.query(` + UPDATE scraped_services + SET raw_data = raw_data - 'ia_paused' + WHERE id = $1 + `, [service.id]); + + await pool.query(` + INSERT INTO service_communications + (scraped_id, owner_id, sender_name, sender_role, message, is_internal) + VALUES ($1, $2, 'Sistema', 'system', $3, true) + `, [service.id, ownerId, "🟢 IA Reactivada manualmente con Emoji."]); + + console.log(`🟢 [IA ACTIVADA] ${service.service_ref}`); + } catch (err) { + console.error("Error activando IA:", err); + } + return; + } + + // Guardamos mensajes humanos salientes + try { + await pool.query(` + INSERT INTO service_communications + (scraped_id, owner_id, sender_name, sender_role, message, is_internal) + VALUES ($1, $2, 'Técnico (WhatsApp)', 'operario', $3, false) + `, [service.id, ownerId, mensajeTexto]); + } catch (err) { + console.error("Error guardando mensaje humano saliente:", err); + } + + return; + } + + // ========================================== + // 👤 MENSAJE ENTRANTE DEL CLIENTE + // ========================================== + try { + await pool.query(` + INSERT INTO service_communications + (scraped_id, owner_id, sender_name, sender_role, message, is_internal) + VALUES ($1, $2, 'Cliente', 'user', $3, false) + `, [service.id, ownerId, mensajeTexto]); + } catch (err) { + console.error("Error guardando mensaje del cliente:", err); + } + + // Candado para que no responda dos veces a la vez + if (candadosIA.has(service.id)) return; + candadosIA.add(service.id); + + try { + // 🛑 Si está pausada, no contestamos + if (service.ia_paused === true || service.ia_paused === 'true') { + console.log(`🤫 [IA MUTEADA] ${service.service_ref}`); + return; + } + + // 🛡️ Escudo humano: si alguien de oficina/operario habló hace menos de 120 min, la IA calla + const checkHumanQ = await pool.query(` + SELECT sender_role, created_at + FROM service_communications + WHERE scraped_id = $1 + AND sender_role IN ('admin', 'superadmin', 'operario') + ORDER BY created_at DESC + LIMIT 1 + `, [service.id]); + + if (checkHumanQ.rowCount > 0) { + const lastMsg = checkHumanQ.rows[0]; + const diffMinutos = (Date.now() - new Date(lastMsg.created_at).getTime()) / (1000 * 60); + + if (diffMinutos < 120) { + console.log(`🛡️ [ESCUDO IA] Silenciada porque un humano habló hace ${Math.round(diffMinutos)} minutos.`); + return; + } + } + + // 🧠 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, + hora_cita: service.hora_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, + compania: service.compania, + averia: service.averia + }); + + if (!respuestaIA) return; + + const matchPropuesta = respuestaIA.match(/\[PROPUESTA:\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\]/i); + let textoLimpio = respuestaIA.replace(/\[PROPUESTA:.*?\]/gi, "").trim(); + + if (matchPropuesta && service.assigned_to) { + const fechaSugerida = matchPropuesta[1]; + const horaSugerida = matchPropuesta[2]; + + const disponibilidad = await comprobarDisponibilidad( + ownerId, + service.assigned_to, + fechaSugerida, + horaSugerida, + 60, + service.id + ); + + if (disponibilidad.choca) { + console.log(`⛔ [DOBLE-BOOKING EVITADO] ${service.service_ref} chocaba con ${disponibilidad.ref}`); + textoLimpio = "Uy, justo ese hueco acaba de quedar ocupado en el sistema. Dime otra hora y lo reviso."; + } else { + 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]); + } + } + + if (!textoLimpio) return; + + await sendWhatsAppAuto(telefonoCliente, textoLimpio, instanceName, true); + + await pool.query(` + INSERT INTO service_communications + (scraped_id, owner_id, sender_name, sender_role, message, is_internal) + VALUES ($1, $2, 'Asistente IA', 'ia', $3, false) + `, [service.id, ownerId, textoLimpio]); + + } finally { + candadosIA.delete(service.id); + } + } catch (e) { - console.error("❌ [WEBHOOK ERROR]:", e.message); + console.error("❌ [WEBHOOK ERROR]:", e); + if (!res.headersSent) return res.sendStatus(200); } });