Actualizar server.js
This commit is contained in:
237
server.js
237
server.js
@@ -827,151 +827,123 @@ async function registrarMovimiento(serviceId, userId, action, details) {
|
||||
} catch (e) { console.error("Error Robot Notario:", e); }
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 🧠 CEREBRO IA (WHATSAPP)
|
||||
// ==========================================
|
||||
async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
|
||||
// 🤖 WEBHOOK CON ESCUDO HUMANO, MEMORIA Y GESTIÓN DE URGENCIAS
|
||||
app.post("/webhook/evolution", async (req, res) => {
|
||||
try {
|
||||
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";
|
||||
const data = req.body;
|
||||
if (data.event !== "messages.upsert") return res.sendStatus(200);
|
||||
|
||||
console.log("🕵️ CHIVATO PROMPT EXTRA:", instruccionesExtra);
|
||||
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;
|
||||
|
||||
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 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',
|
||||
content: row.message
|
||||
}));
|
||||
const esPrimerMensaje = historialChat.length === 0;
|
||||
|
||||
let agendaOcupadaTexto = "El técnico tiene la agenda libre en horario laboral.";
|
||||
if (datosExpediente.worker_id) {
|
||||
const agendaQ = await pool.query("SELECT raw_data->>'scheduled_date' as date, raw_data->>'scheduled_time' as time, raw_data->>'Población' as pob FROM scraped_services WHERE assigned_to = $1 AND raw_data->>'scheduled_date' >= CURRENT_DATE::text AND status != 'archived' AND id != $2 ORDER BY date ASC, time ASC", [datosExpediente.worker_id, datosExpediente.dbId]);
|
||||
if (agendaQ.rowCount > 0) {
|
||||
const ocupaciones = {};
|
||||
agendaQ.rows.forEach(r => { if(r.date && r.time) { if(!ocupaciones[r.date]) ocupaciones[r.date] = []; ocupaciones[r.date].push(`${r.time} (en ${r.pob || 'otra zona'})`); } });
|
||||
const lineas = Object.keys(ocupaciones).map(d => `- Día ${d}: Ocupado a las ${ocupaciones[d].join(", ")}`);
|
||||
if(lineas.length > 0) {
|
||||
agendaOcupadaTexto = "Citas actuales:\n " + lineas.join("\n ") +
|
||||
"\n 👉 IMPORTANTE: Todas las demás horas (09:00, 11:00, 12:00, etc.) ESTÁN TOTALMENTE LIBRES. Ofrécelas sin miedo.";
|
||||
}
|
||||
}
|
||||
if (!mensajeTexto || !instanceName || !instanceName.startsWith("cliente_")) {
|
||||
return res.sendStatus(200);
|
||||
}
|
||||
|
||||
const hayCitaPendiente = datosExpediente.appointment_status === 'pending' && datosExpediente.cita_pendiente_fecha;
|
||||
const tieneCitaConfirmada = datosExpediente.cita && datosExpediente.cita !== 'Ninguna';
|
||||
const esUrgencia = datosExpediente.is_urgent;
|
||||
// 🚀 CRÍTICO: Responder a Evolution rápido para que no reintente
|
||||
res.sendStatus(200);
|
||||
|
||||
// 🕐 CONVERTIMOS LA HORA PENDIENTE EN TRAMO DE 1 HORA
|
||||
let tramoPendiente = datosExpediente.cita_pendiente_hora || "";
|
||||
if (tramoPendiente && tramoPendiente.includes(":")) {
|
||||
let [h, m] = tramoPendiente.split(':');
|
||||
let hEnd = String((parseInt(h) + 1) % 24).padStart(2, '0');
|
||||
tramoPendiente = `entre las ${h}:${m} y las ${hEnd}:${m} aprox`;
|
||||
const ownerId = instanceName.split("_")[1];
|
||||
const cleanPhone = telefonoCliente.slice(-9);
|
||||
|
||||
// 🔍 BUSCAMOS EL EXPEDIENTE ACTIVO MÁS RECIENTE (Ignorando finalizados/anulados)
|
||||
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
|
||||
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}%`]);
|
||||
|
||||
if (svcQ.rowCount > 0) {
|
||||
const service = svcQ.rows[0];
|
||||
|
||||
if (data.data.key.fromMe) {
|
||||
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]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 🕐 CONVERTIMOS LA HORA CONFIRMADA EN TRAMO DE 1 HORA
|
||||
let tramoConfirmado = datosExpediente.hora_cita || "";
|
||||
if (tramoConfirmado && tramoConfirmado.includes(":")) {
|
||||
let [h, m] = tramoConfirmado.split(':');
|
||||
let hEnd = String((parseInt(h) + 1) % 24).padStart(2, '0');
|
||||
tramoConfirmado = `entre las ${h}:${m} y las ${hEnd}:${m} aprox`;
|
||||
} else {
|
||||
tramoConfirmado = 'una hora por confirmar';
|
||||
if (candadosIA.has(service.id)) return;
|
||||
candadosIA.add(service.id);
|
||||
|
||||
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 (checkHumanQ.rowCount > 0) {
|
||||
const lastMsg = checkHumanQ.rows[0];
|
||||
const diffMinutos = (new Date() - new Date(lastMsg.created_at)) / (1000 * 60);
|
||||
// 🚨 ESCUDO HUMANO TEMPORAL: Puesto a 0 para que puedas probar. ¡Acuérdate de ponerlo a 120 cuando acabes!
|
||||
if (['admin', 'superadmin', 'operario'].includes(lastMsg.sender_role) && diffMinutos < 0) return;
|
||||
}
|
||||
|
||||
// 🛑 NUEVO: 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 [y, m, d] = datosExpediente.cita.split('-');
|
||||
const citaTime = new Date(y, m - 1, d).setHours(0,0,0,0);
|
||||
if (citaTime < hoyTime) citaYaPaso = true;
|
||||
}
|
||||
|
||||
// 🛑 NUEVO: DETECTOR DE ESTADO FINALIZADO
|
||||
const esEstadoFinal = datosExpediente.estado && (datosExpediente.estado.toLowerCase().includes('finalizado') || datosExpediente.estado.toLowerCase().includes('terminado') || datosExpediente.estado.toLowerCase().includes('anulado'));
|
||||
|
||||
// 🛑 NUEVO: DETECTOR DE SEGURO VS PARTICULAR
|
||||
const nombreCia = datosExpediente.compania || "su Aseguradora";
|
||||
const esSeguro = !nombreCia.toLowerCase().includes('particular');
|
||||
|
||||
let directivaEstricta = "";
|
||||
|
||||
if (esEstadoFinal) {
|
||||
if (esSeguro) {
|
||||
directivaEstricta = `🛑 ESTADO ACTUAL: SERVICIO CERRADO POR EL TÉCNICO.\nTU ÚNICO OBJETIVO: Informar al cliente que el informe ya ha sido enviado a ${nombreCia} y que estamos a la espera de que ellos nos den respuesta o autorización para continuar los trabajos.\nPROHIBICIÓN ABSOLUTA: NO intentes agendar cita ni dar horas. No asumas que la reparación está 100% terminada.`;
|
||||
} else {
|
||||
directivaEstricta = `🛑 ESTADO ACTUAL: SERVICIO FINALIZADO O ANULADO.\nTU ÚNICO OBJETIVO: Despedirte amablemente o dar soporte post-servicio si el cliente particular tiene alguna duda.\nPROHIBICIÓN ABSOLUTA: NO intentes agendar cita ni dar horas. El trabajo ya se ha terminado.`;
|
||||
}
|
||||
} else if (citaYaPaso) {
|
||||
if (esSeguro) {
|
||||
directivaEstricta = `🛑 ESTADO ACTUAL: LA CITA YA HA PASADO.\n📅 La cita fue el día: ${datosExpediente.cita}.\nTU ÚNICO OBJETIVO: Informar que el técnico ya acudió a la visita y que actualmente estamos tramitando el informe con ${nombreCia}, a la espera de sus instrucciones.\nPROHIBICIÓN ABSOLUTA: No hables en futuro ni propongas más citas.`;
|
||||
} else {
|
||||
directivaEstricta = `🛑 ESTADO ACTUAL: LA CITA YA HA PASADO.\n📅 La cita fue el día: ${datosExpediente.cita}.\nTU ÚNICO OBJETIVO: Preguntar amablemente al cliente particular si el técnico acudió y si el problema quedó resuelto o si estamos elaborando su presupuesto.\nPROHIBICIÓN ABSOLUTA: No hables en futuro ni agendes.`;
|
||||
}
|
||||
} else if (esUrgencia) {
|
||||
directivaEstricta = `🛑 ESTADO ACTUAL: SERVICIO DE URGENCIA.\nTU ÚNICO OBJETIVO: Tranquilizar al cliente. Dile que al ser urgencia el técnico está avisado.\nPROHIBICIÓN ABSOLUTA: No des cita ni propongas horas.`;
|
||||
} else if (hayCitaPendiente) {
|
||||
directivaEstricta = `🛑 ESTADO ACTUAL: CITA PENDIENTE DE APROBACIÓN POR TÉCNICO.\n📅 Propuesta actual: El día ${datosExpediente.cita_pendiente_fecha} ${tramoPendiente}.\nTU ÚNICO OBJETIVO: Informar al cliente que estamos esperando confirmación del técnico.\n⚠️ REGLA CRÍTICA: Ignora el historial si no coincide con esta propuesta.\nPROHIBICIÓN ABSOLUTA: No agendes de nuevo.`;
|
||||
} else if (tieneCitaConfirmada) {
|
||||
directivaEstricta = `🛑 ESTADO ACTUAL: CITA 100% CONFIRMADA.\n📅 Día: ${datosExpediente.cita}.\n⏰ Tramo horario: ${tramoConfirmado}.\nTU OBJETIVO PRINCIPAL: Recordar la cita actual.\n\n⚠️ EXCEPCIÓN CRÍTICA (REAGENDAR): \nSi el cliente te dice que NO PUEDE ir, o pide CAMBIAR, MODIFICAR o ANULAR la cita, DEBES PERMITIRLO:\n1. Dile que no hay problema en cambiarla.\n2. Mira la "AGENDA DEL TÉCNICO" y ofrécele un hueco libre nuevo.\n3. Si acepta el nuevo hueco, ⚠️ INDÍSCALE EXPRESAMENTE que le pasas la nota al técnico para que él se lo confirme (NUNCA digas que ya está 100% confirmada). MANDA OBLIGATORIAMENTE el código: [PROPUESTA:YYYY-MM-DD HH:mm]`;
|
||||
} else {
|
||||
directivaEstricta = `🟢 ESTADO ACTUAL: PENDIENTE DE AGENDAR CITA.\nTU OBJETIVO: Acordar fecha y hora.\nREGLAS: Nunca ofrezcas horas ocupadas. Fines de semana solo URGENCIAS. \n⚠️ MUY IMPORTANTE: Si el cliente ACEPTA un hueco, aclárale que le pasas la propuesta al técnico para que él dé la confirmación final (no lo des por asegurado al 100%). Añade AL FINAL el código: [PROPUESTA:YYYY-MM-DD HH:mm]`;
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
--- 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.
|
||||
|
||||
--- AGENDA DEL TÉCNICO ASIGNADO ---
|
||||
${agendaOcupadaTexto}
|
||||
|
||||
--- 🎯 DIRECTIVA ESTRICTA PARA ESTE MENSAJE ---
|
||||
${directivaEstricta}
|
||||
|
||||
--- 🗓️ REGLAS ESTRICTAS DE FECHA Y HORA (FORMATO HUMANO) ---
|
||||
1. DÍAS DE LA SEMANA: NUNCA uses fechas frías como "2026-03-10". Usa SIEMPRE el día de la semana y el mes (Ej: "el martes de la semana que viene", "el jueves día 12").
|
||||
2. TRAMOS HORARIOS: NUNCA le des una hora exacta al cliente. Usa SIEMPRE un margen de 1 hora. (Ej: "entre las 10:00 y las 11:00 aprox").
|
||||
3. (Si estás agendando): Aunque hables en tramos, tu código interno de cierre DEBE SER la hora exacta de inicio en formato reloj: [PROPUESTA:YYYY-MM-DD HH:mm]
|
||||
|
||||
--- 📝 INSTRUCCIONES PERSONALIZADAS DE LA EMPRESA ---
|
||||
${instruccionesExtra ? instruccionesExtra : 'No hay reglas extra.'}
|
||||
|
||||
--- REGLAS DE ORO DE COMUNICACIÓN ---
|
||||
0. LA BASE DE DATOS MANDA: Los datos del "ESTADO ACTUAL" son la única verdad. Si contradicen el historial, la oficina ha modificado la cita.
|
||||
1. Máximo 2 frases. Mensajes cortos y directos.
|
||||
2. ⛔ MULETILLAS PROHIBIDAS: NUNCA termines tus frases diciendo "Si necesitas algo más, aquí estoy", "¿En qué más te puedo ayudar?" o similares. Suena a contestador automático. Da la información y pon un punto y final.
|
||||
3. NO TE PRESENTES si ya habéis intercambiado mensajes antes.
|
||||
${esPrimerMensaje ? '4. Primer mensaje: preséntate brevemente diciendo de dónde eres y el aviso (#' + datosExpediente.ref + ').' : ''}
|
||||
`;
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [{ role: "system", content: promptSistema }, ...historialChat, { role: "user", content: mensajeCliente }],
|
||||
temperature: 0.1,
|
||||
// 🧠 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
|
||||
});
|
||||
|
||||
return completion.choices[0].message.content;
|
||||
if (respuestaIA) {
|
||||
// 🛡️ REGEX BLINDADO: Ignora espacios extra dentro de los corchetes
|
||||
const matchPropuesta = respuestaIA.match(/\[PROPUESTA:\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\]/i);
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
const textoLimpio = respuestaIA.replace(/\[PROPUESTA:.*?\]/i, "").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 {
|
||||
candadosIA.delete(service.id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("❌ Error OpenAI:", e.message);
|
||||
return null;
|
||||
}
|
||||
console.error("❌ [WEBHOOK ERROR]:", e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 📱 OTP PARA PORTAL DEL CLIENTE (ACCESO WEB)
|
||||
@@ -3970,7 +3942,8 @@ app.post("/webhook/evolution", async (req, res) => {
|
||||
});
|
||||
|
||||
if (respuestaIA) {
|
||||
const matchPropuesta = respuestaIA.match(/\[PROPUESTA:(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})\]/);
|
||||
// Expresión regular mejorada (i) y con \s* para ignorar si la IA pone espacios extra
|
||||
const matchPropuesta = respuestaIA.match(/\[PROPUESTA:\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\]/i);
|
||||
if (matchPropuesta) {
|
||||
const fechaSugerida = matchPropuesta[1];
|
||||
const horaSugerida = matchPropuesta[2];
|
||||
|
||||
Reference in New Issue
Block a user