diff --git a/server.js b/server.js index 8550ed3..39c4fcb 100644 --- a/server.js +++ b/server.js @@ -497,37 +497,88 @@ function genCode6() { return String(Math.floor(100000 + Math.random() * 900000)) // 🛡️ ESCUDO DE TITANIO: ANTI-SOLAPAMIENTOS BBDD // ========================================== async function comprobarDisponibilidad(ownerId, workerId, date, time, durationMin, excludeId = null) { - if (!workerId || !date || !time) return { choca: false }; - - let [newH, newM] = time.split(':').map(Number); - let newStart = newH * 60 + newM; - let newEnd = newStart + (parseInt(durationMin) || 60); - - let query = ` - SELECT id, service_ref, raw_data->>'scheduled_time' as time, raw_data->>'duration_minutes' as dur, - raw_data->>'requested_time' as req_time, raw_data->>'appointment_status' as app_status - FROM scraped_services - WHERE owner_id = $1 AND assigned_to = $2 AND status != 'archived' - AND ( - raw_data->>'scheduled_date' = $3 OR - (raw_data->>'requested_date' = $3 AND raw_data->>'appointment_status' = 'pending') - ) - `; - let params = [ownerId, workerId, date]; - if (excludeId) { query += ` AND id != $4`; params.push(excludeId); } - - const q = await pool.query(query, params); - - for (const s of q.rows) { - let sTime = s.time || (s.app_status === 'pending' ? s.req_time : null); - if (sTime && sTime.includes(':')) { - let [sH, sM] = sTime.split(':').map(Number); - let sStart = sH * 60 + sM; - let sEnd = sStart + parseInt(s.dur || 60); - if (newStart < sEnd && newEnd > sStart) return { choca: true, ref: s.service_ref, time: sTime }; + try { + if (!ownerId || !workerId || !date || !time) { + return { choca: false }; } + + const parseTimeToMinutes = (value) => { + if (!value) return null; + const parts = String(value).trim().split(":"); + if (parts.length < 2) return null; + const hh = parseInt(parts[0], 10); + const mm = parseInt(parts[1], 10); + if (Number.isNaN(hh) || Number.isNaN(mm)) return null; + return (hh * 60) + mm; + }; + + const requestedStart = parseTimeToMinutes(time); + const requestedDuration = Math.max(parseInt(durationMin, 10) || 60, 1); + + if (requestedStart === null) { + return { choca: false }; + } + + const requestedEnd = requestedStart + requestedDuration; + + let query = ` + SELECT + id, + service_ref, + COALESCE( + NULLIF(raw_data->>'scheduled_time', ''), + CASE + WHEN raw_data->>'appointment_status' = 'pending' THEN NULLIF(raw_data->>'requested_time', '') + ELSE NULL + END + ) as effective_time, + COALESCE( + NULLIF(raw_data->>'duration_minutes', ''), + '60' + ) as effective_duration + FROM scraped_services + WHERE owner_id = $1 + AND assigned_to = $2 + AND status != 'archived' + AND ( + raw_data->>'scheduled_date' = $3 + OR ( + raw_data->>'appointment_status' = 'pending' + AND raw_data->>'requested_date' = $3 + ) + ) + `; + + const params = [ownerId, workerId, date]; + + if (excludeId) { + query += ` AND id != $4`; + params.push(excludeId); + } + + const q = await pool.query(query, params); + + for (const row of q.rows) { + const start = parseTimeToMinutes(row.effective_time); + if (start === null) continue; + + const duration = Math.max(parseInt(row.effective_duration, 10) || 60, 1); + const end = start + duration; + + if (requestedStart < end && requestedEnd > start) { + return { + choca: true, + ref: row.service_ref || `ID-${row.id}`, + time: row.effective_time + }; + } + } + + return { choca: false }; + } catch (e) { + console.error("❌ Error en comprobarDisponibilidad:", e.message); + return { choca: false }; } - return { choca: false }; } // ========================================== @@ -938,8 +989,215 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { day: "numeric" }).format(new Date()); - // Historial REAL del chat (ya incluye el último mensaje del cliente - // si el webhook lo ha guardado antes de llamar a esta función) + // ========================================================= + // HELPERS INTERNOS (AUTOCONTENIDOS, PARA NO ROMPER TU SERVER) + // ========================================================= + const parseTimeToMinutes = (value) => { + if (!value) return null; + const parts = String(value).trim().split(":"); + if (parts.length < 2) return null; + const hh = parseInt(parts[0], 10); + const mm = parseInt(parts[1], 10); + if (Number.isNaN(hh) || Number.isNaN(mm)) return null; + return (hh * 60) + mm; + }; + + const minutesToHHMM = (mins) => { + const safe = Math.max(0, mins); + const hh = String(Math.floor(safe / 60)).padStart(2, "0"); + const mm = String(safe % 60).padStart(2, "0"); + return `${hh}:${mm}`; + }; + + const roundUpTo30 = (mins) => Math.ceil(mins / 30) * 30; + const roundDownTo30 = (mins) => Math.floor(mins / 30) * 30; + + const mergeIntervals = (intervals) => { + if (!Array.isArray(intervals) || intervals.length === 0) return []; + const sorted = [...intervals].sort((a, b) => a[0] - b[0]); + const merged = [sorted[0]]; + + for (let i = 1; i < sorted.length; i++) { + const [curStart, curEnd] = sorted[i]; + const last = merged[merged.length - 1]; + + if (curStart <= last[1]) { + last[1] = Math.max(last[1], curEnd); + } else { + merged.push([curStart, curEnd]); + } + } + + return merged; + }; + + const getMadridNowInfo = () => { + const fmt = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Madrid", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false + }); + + const parts = fmt.formatToParts(new Date()); + const map = {}; + for (const p of parts) { + if (p.type !== "literal") map[p.type] = p.value; + } + + return { + todayISO: `${map.year}-${map.month}-${map.day}`, + currentMinutes: (parseInt(map.hour, 10) * 60) + parseInt(map.minute, 10) + }; + }; + + const addDaysISO = (isoDate, daysToAdd) => { + const [y, m, d] = isoDate.split("-").map(Number); + const dt = new Date(Date.UTC(y, m - 1, d)); + dt.setUTCDate(dt.getUTCDate() + daysToAdd); + return dt.toISOString().slice(0, 10); + }; + + const isWeekendISO = (isoDate) => { + const [y, m, d] = isoDate.split("-").map(Number); + const dt = new Date(Date.UTC(y, m - 1, d)); + const day = dt.getUTCDay(); + return day === 0 || day === 6; + }; + + const formatDisplayDateES = (isoDate) => { + const [y, m, d] = isoDate.split("-").map(Number); + return new Date(y, m - 1, d, 12, 0, 0).toLocaleDateString("es-ES", { + weekday: "long", + day: "numeric", + month: "long" + }); + }; + + const calcularHuecosDisponiblesExactos = async () => { + if (!datosExpediente.worker_id) return []; + + const agendaQ = await pool.query(` + SELECT + COALESCE( + NULLIF(raw_data->>'scheduled_date', ''), + CASE + WHEN raw_data->>'appointment_status' = 'pending' THEN NULLIF(raw_data->>'requested_date', '') + ELSE NULL + END + ) as effective_date, + COALESCE( + NULLIF(raw_data->>'scheduled_time', ''), + CASE + WHEN raw_data->>'appointment_status' = 'pending' THEN NULLIF(raw_data->>'requested_time', '') + ELSE NULL + END + ) as effective_time, + COALESCE(NULLIF(raw_data->>'duration_minutes', ''), '60') as effective_duration + FROM scraped_services + 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 effective_date ASC, effective_time ASC + `, [ownerId, datosExpediente.worker_id, datosExpediente.dbId]); + + const ocupacionesPorDia = {}; + + for (const row of agendaQ.rows) { + const isoDate = row.effective_date; + const startMin = parseTimeToMinutes(row.effective_time); + const duration = Math.max(parseInt(row.effective_duration, 10) || 60, 1); + + if (!isoDate || startMin === null) continue; + + if (!ocupacionesPorDia[isoDate]) ocupacionesPorDia[isoDate] = []; + ocupacionesPorDia[isoDate].push([startMin, startMin + duration]); + } + + const sesionesTrabajo = [ + [parseTimeToMinutes(horarios.m_start), parseTimeToMinutes(horarios.m_end)], + [parseTimeToMinutes(horarios.a_start), parseTimeToMinutes(horarios.a_end)] + ].filter(([ini, fin]) => ini !== null && fin !== null && fin > ini); + + const { todayISO, currentMinutes } = getMadridNowInfo(); + const diasDisponibles = []; + + for (let offset = 0; offset < 21 && diasDisponibles.length < 10; offset++) { + const isoDate = addDaysISO(todayISO, offset); + if (isWeekendISO(isoDate)) continue; + + const ocupadas = mergeIntervals(ocupacionesPorDia[isoDate] || []); + const ventanas = []; + + for (const [sesionStartBase, sesionEndBase] of sesionesTrabajo) { + let sesionStart = sesionStartBase; + const sesionEnd = sesionEndBase; + + if (isoDate === todayISO) { + sesionStart = Math.max(sesionStart, roundUpTo30(currentMinutes + 30)); + } + + if ((sesionEnd - sesionStart) < 60) continue; + + let cursor = sesionStart; + + for (const [occStart, occEnd] of ocupadas) { + if (occEnd <= sesionStart) continue; + if (occStart >= sesionEnd) break; + + const gapStart = roundUpTo30(cursor); + const gapEnd = roundDownTo30(Math.min(occStart, sesionEnd)); + + if ((gapEnd - gapStart) >= 60) { + ventanas.push({ + start: minutesToHHMM(gapStart), + end: minutesToHHMM(gapEnd), + startMin: gapStart, + endMin: gapEnd + }); + } + + cursor = Math.max(cursor, occEnd); + if (cursor >= sesionEnd) break; + } + + const tailStart = roundUpTo30(cursor); + const tailEnd = roundDownTo30(sesionEnd); + + if ((tailEnd - tailStart) >= 60) { + ventanas.push({ + start: minutesToHHMM(tailStart), + end: minutesToHHMM(tailEnd), + startMin: tailStart, + endMin: tailEnd + }); + } + } + + if (ventanas.length > 0) { + diasDisponibles.push({ + date: isoDate, + displayDate: formatDisplayDateES(isoDate), + windows: ventanas + }); + } + } + + return diasDisponibles; + }; + + // ========================================================= + // HISTORIAL REAL DEL CHAT + // ========================================================= const historyQ = await pool.query(` SELECT sender_role, message FROM service_communications @@ -956,70 +1214,27 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { content: row.message })); - // Si el historial solo tiene el mensaje actual del cliente, es primer contacto const esPrimerMensaje = historialRows.length <= 1; - let agendaOcupadaTexto = "✅ El técnico tiene la agenda libre en horario laboral."; + // ========================================================= + // HUECOS EXACTOS CALCULADOS POR BACKEND + // ========================================================= + let huecosExactos = []; + let agendaDisponibleTexto = "❌ Ahora mismo no hay huecos exactos calculados por el sistema."; if (datosExpediente.worker_id) { - 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, - COALESCE(raw_data->>'Población', raw_data->>'POBLACION-PROVINCIA') as pob, - provider - FROM scraped_services - 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 - `, [ownerId, datosExpediente.worker_id, datosExpediente.dbId]); + huecosExactos = await calcularHuecosDisponiblesExactos(); - if (agendaQ.rowCount > 0) { - const ocupaciones = {}; + if (huecosExactos.length > 0) { + agendaDisponibleTexto = huecosExactos.map(dia => { + const tramos = dia.windows + .map(w => `entre las ${w.start} y las ${w.end} aprox`) + .join(" | "); - agendaQ.rows.forEach(r => { - if (r.date && r.time && r.time.includes(':')) { - if (!ocupaciones[r.date]) ocupaciones[r.date] = []; - - const [h, m] = r.time.split(':').map(Number); - const dur = parseInt(r.duration || 60, 10); - const endMin = (h * 60 + m) + dur; - const endH = String(Math.floor(endMin / 60) % 24).padStart(2, '0'); - const endM = String(endMin % 60).padStart(2, '0'); - - 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', { - weekday: 'long', - day: 'numeric', - month: 'long' - }); - - return `- Día ${fechaHumana} (${d}):\n * ${ocupaciones[d].join("\n * ")}`; - }); - - if (lineas.length > 0) { - 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."; - } + return `- ${dia.displayDate} (${dia.date}): ${tramos}`; + }).join("\n"); + } else { + agendaDisponibleTexto = "❌ No hay huecos exactos libres en los próximos días laborables calculados por el sistema."; } } @@ -1083,47 +1298,63 @@ TU ÚNICO OBJETIVO: Informar al cliente que hemos recibido el aviso de su averí directivaEstricta = `🛑 ESTADO ACTUAL: CITA PENDIENTE DE APROBACIÓN. 📅 Propuesta actual: El día ${datosExpediente.cita_pendiente_fecha} ${tramoPendiente}. TU OBJETIVO: Informar que esperamos confirmación del técnico para reparar su avería (${datosExpediente.averia}). -⚠️ EXCEPCIÓN: Si el cliente pide CAMBIAR o CANCELAR, ofrécele un hueco nuevo.`; +⚠️ EXCEPCIÓN: Si el cliente pide CAMBIAR o CANCELAR, ofrécele un hueco nuevo usando SOLO los huecos exactos calculados por el sistema.`; } else if (tieneCitaConfirmada) { directivaEstricta = `🛑 ESTADO ACTUAL: CITA CONFIRMADA para el ${datosExpediente.cita} ${tramoConfirmado}. Recuerda la cita para su avería (${datosExpediente.averia}). -⚠️ EXCEPCIÓN: Si el cliente pide CAMBIARLA o CANCELARLA, ofrécele un hueco nuevo.`; +⚠️ EXCEPCIÓN: Si el cliente pide CAMBIARLA o CANCELARLA, ofrécele un hueco nuevo usando SOLO los huecos exactos calculados por el sistema.`; } else { directivaEstricta = `🟢 ESTADO ACTUAL: PENDIENTE DE AGENDAR CITA. -TU OBJETIVO: Acordar fecha y hora para reparar su avería (${datosExpediente.averia}). NUNCA ofrezcas horas ocupadas. Fines de semana solo URGENCIAS. +TU OBJETIVO: Acordar fecha y hora para reparar su avería (${datosExpediente.averia}) usando SOLO los huecos exactos calculados por el sistema. ⚠️ MUY IMPORTANTE: Cuando el cliente elija un hueco, NO le digas que la cita está confirmada. Dile que le pasas la nota al técnico para que él lo valide.`; } const promptSistema = ` -Eres el coordinador humano de "${empresaNombre}". Hablas de tú, de forma muy natural, empática y con buen tono por WhatsApp. +Eres el coordinador humano de "${empresaNombre}". Hablas de tú, de forma natural, clara, cercana y profesional por WhatsApp. --- 📋 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. +- ⛔ REGLA DE ORO DEL HORARIO: NUNCA propongas horas fuera de ese horario ni cruces el tramo de comer. +- Fines de semana solo si es una urgencia. - Localidad del cliente actual: ${datosExpediente.poblacion || 'Localidad no especificada'}. ---- 📅 REVISIÓN DE AGENDA (EVITAR SOLAPES) --- -${agendaOcupadaTexto} +--- ✅ HUECOS EXACTOS CALCULADOS POR EL SISTEMA --- +${agendaDisponibleTexto} + +--- 🚨 NORMA ABSOLUTA DE AGENDA --- +Los huecos anteriores son la ÚNICA verdad. +NO puedes inventar horas. +NO puedes deducir huecos por tu cuenta. +NO puedes reinterpretar horarios ocupados. +SOLO puedes ofrecer horas que estén dentro de esos huecos exactos calculados por el sistema. +Si el cliente pide una hora concreta y esa hora cae dentro de uno de esos huecos, puedes aceptarla tentativamente. +Si cae fuera, debes decir que no la ves libre y ofrecer una alternativa de la lista exacta. --- 🎯 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: +Si (y solo si) el cliente ACEPTA claramente un hueco concreto, DEBES añadir AL FINAL ABSOLUTO de tu respuesta este texto literal: [PROPUESTA:YYYY-MM-DD HH:mm] 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]" +"Perfecto, le paso la nota al técnico para que te confirme el miércoles entre las 12:00 y las 13:00 aprox. ¡Te decimos algo pronto! [PROPUESTA:2026-03-25 12: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". +⛔ PROHIBICIONES: +- NUNCA digas "te agendo". +- NUNCA digas "cita confirmada". +- NUNCA menciones las palabras "código" o "etiqueta". +- NUNCA ofrezcas huecos distintos a los calculados. +- Si el cliente pregunta por "la semana que viene", responde usando solo los huecos de esa semana. --- ⚙️ 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}` : ''} +1. MÁXIMO 2 FRASES. +2. Mensajes cortos, claros y directos. +3. NO TE PRESENTES si ya habéis hablado antes. +4. NO uses la muletilla "¿En qué más te puedo ayudar?". +5. Si el cliente pide disponibilidad general, resume las opciones reales sin inventar nada. +${esPrimerMensaje ? `6. Primer mensaje: preséntate y menciona el aviso (#${datosExpediente.ref}).` : ''} +${instruccionesExtra ? `7. Instrucción extra de la empresa: ${instruccionesExtra}` : ''} `; const completion = await openai.chat.completions.create({ @@ -1142,7 +1373,6 @@ ${instruccionesExtra ? `6. Instrucción extra de la empresa: ${instruccionesExtr } } - // ========================================== // 📱 OTP PARA PORTAL DEL CLIENTE (ACCESO WEB) // ========================================== @@ -4122,10 +4352,14 @@ app.post("/webhook/evolution", async (req, res) => { return res.sendStatus(200); } + // Ignorar grupos, estados y cosas raras + if (!/@s\.whatsapp\.net$/i.test(remoteJid)) { + return res.sendStatus(200); + } + // Respondemos rápido a Evolution res.sendStatus(200); - // Tu arquitectura actual depende de cliente_ID if (!/^cliente_\d+$/.test(instanceName)) { console.log(`⚠️ [WEBHOOK IA] Instancia ignorada: ${instanceName}`); return; @@ -4155,7 +4389,10 @@ app.post("/webhook/evolution", async (req, res) => { 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 + CASE + WHEN LOWER(COALESCE(s.raw_data->>'ia_paused', 'false')) IN ('true', 't', '1') THEN true + ELSE false + END 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 @@ -4272,7 +4509,6 @@ app.post("/webhook/evolution", async (req, res) => { 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); @@ -4283,7 +4519,7 @@ app.post("/webhook/evolution", async (req, res) => { return; } - // 🛡️ Escudo humano: si alguien de oficina/operario habló hace menos de 120 min, la IA calla + // 🛡️ Escudo humano: si alguien habló hace menos de 120 min, la IA se calla const checkHumanQ = await pool.query(` SELECT sender_role, created_at FROM service_communications @@ -4303,7 +4539,7 @@ app.post("/webhook/evolution", async (req, res) => { } } - // 🧠 Llamada a la IA + // 🧠 LLAMADA A LA IA const respuestaIA = await procesarConIA(ownerId, mensajeTexto, { dbId: service.id, ref: service.service_ref, @@ -4324,7 +4560,10 @@ app.post("/webhook/evolution", async (req, res) => { 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(); + let textoLimpio = respuestaIA + .replace(/\[PROPUESTA:.*?\]/gi, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); if (matchPropuesta && service.assigned_to) { const fechaSugerida = matchPropuesta[1]; @@ -4341,7 +4580,7 @@ app.post("/webhook/evolution", async (req, res) => { 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."; + textoLimpio = "Uy, justo ese hueco ya no lo veo libre en el sistema. Dime otra hora y lo reviso."; } else { await pool.query(` UPDATE scraped_services