Actualizar server.js

This commit is contained in:
2026-04-03 10:26:29 +00:00
parent 1dd50bc820
commit e97115b3b0

466
server.js
View File

@@ -908,68 +908,96 @@ 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
WHERE owner_id = $1
AND assigned_to = $2
AND status != 'archived'
AND id != $2
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)
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';
const tipo = r.provider === 'SYSTEM_BLOCK' ? 'BLOQUEO/AUSENCIA' : 'CITA';
const lugar = r.pob || 'Otra zona';
ocupaciones[r.date].push(`De ${r.time} a ${endH}:${endM} (${tipo} en ${lugar})`);
ocupaciones[r.date].push(`❌ OCUPADO de ${r.time} a ${endH}:${endM} (${tipo} en ${lugar})`);
}
});
@@ -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,19 +1041,20 @@ 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 = "";
@@ -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,179 +4081,210 @@ 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);
// Tu arquitectura actual depende de cliente_ID
if (!/^cliente_\d+$/.test(instanceName)) {
console.log(`⚠️ [WEBHOOK IA] Instancia ignorada: ${instanceName}`);
return;
}
// 🔍 BUSCAMOS EL EXPEDIENTE ACTIVO MÁS RECIENTE
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,
SELECT
s.id,
s.service_ref,
s.assigned_to,
s.is_urgent,
u.full_name as worker_name,
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
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}%`]);
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) {
console.log(`⚠️ [WEBHOOK IA] No se encontró expediente activo para ${cleanPhone}`);
return;
}
if (svcQ.rowCount > 0) {
const service = svcQ.rows[0];
if (data.data.key.fromMe) {
// ==========================================
// 🧑‍🔧 MENSAJES TUYOS / ADMIN / OFICINA
// ==========================================
if (fromMe) {
const msgCmd = mensajeTexto.trim();
// 🔴 COMANDO MÁGICO 1: PAUSAR LA IA (Resistente a emojis de iPhone)
if (msgCmd.includes('🔴')) {
// 🔴 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]);
// 🛑 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); }
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;
}
// 🟢 COMANDO MÁGICO 2: ACTIVAR LA IA
if (msgCmd.includes('🟢')) {
// 🟢 REACTIVAR 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); }
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;
}
// Guardado normal si es un texto tuyo hablando con el cliente
// Guardamos mensajes humanos salientes
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); }
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 {
// 🛑 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}`);
// 🛑 Si está pausada, no contestamos
if (service.ia_paused === true || service.ia_paused === 'true') {
console.log(`🤫 [IA MUTEADA] ${service.service_ref}`);
return;
}
// 🛡️ VERIFICAR INTERVENCIÓN HUMANA (ESCUDO INTELIGENTE)
// Buscamos el último mensaje, PERO ignoramos al 'system' y al 'user'
// 🛡️ 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 NOT IN ('user', 'system')
ORDER BY created_at DESC LIMIT 1
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 = (new Date() - new Date(lastMsg.created_at)) / (1000 * 60);
const diffMinutos = (Date.now() - new Date(lastMsg.created_at).getTime()) / (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.`);
if (diffMinutos < 120) {
console.log(`🛡️ [ESCUDO IA] Silenciada porque un humano habló hace ${Math.round(diffMinutos)} minutos.`);
return;
}
}
// 🧠 LLAMADA A LA IA
// 🧠 Llamada a la IA
const respuestaIA = await procesarConIA(ownerId, mensajeTexto, {
dbId: service.id,
ref: service.service_ref,
@@ -4236,18 +4302,15 @@ app.post("/webhook/evolution", async (req, res) => {
averia: service.averia
});
if (respuestaIA) {
// 🛡️ REGEX BLINDADO: Pilla la etiqueta aunque la IA meta espacios raros
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();
// 🧹 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) {
if (matchPropuesta && service.assigned_to) {
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,
@@ -4258,33 +4321,38 @@ app.post("/webhook/evolution", async (req, res) => {
);
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?";
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 {
// 🚀 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
)
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) VALUES ($1, $2, $3, $4, $5)`,
[service.id, ownerId, "Asistente IA", "ia", textoLimpio]
);
}
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);
}
});