From a829f0fd09d728d0aeb3014bc62763ab3cd55d6d Mon Sep 17 00:00:00 2001 From: marsalva Date: Sat, 12 Sep 2026 16:33:07 +0000 Subject: [PATCH] Subir archivos a "/" --- server.js | 282 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 277 insertions(+), 5 deletions(-) diff --git a/server.js b/server.js index fdcf510..2c4f1c6 100644 --- a/server.js +++ b/server.js @@ -20,6 +20,12 @@ const candadosIA = new Set(); const debounceTimersIA = new Map(); const IA_DEBOUNCE_MS = 8000; +// 👑 ESTADO DEL MINI-DIÁLOGO DE COMANDOS DE ADMIN (por owner): cuando el admin escribe +// "escribe a [teléfono]" desde su móvil personal, guardamos aquí que estamos esperando +// que nos diga el motivo, antes de contactar al cliente. Es en memoria (si el servidor +// reinicia a mitad, el admin simplemente tiene que repetir el comando). +const adminIntakeState = new Map(); + // Configuración de CORS Profesional const corsOptions = { origin: [ @@ -1935,6 +1941,115 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { const esPrimerMensaje = historialRows.length <= 1; + // ========================================================= + // 🆕 MODO CAPTACIÓN: expedientes creados con el comando de admin + // "escribe a [teléfono]" (todavía sin nombre/dirección/avería). Aquí extraemos + // lo que el cliente acaba de aportar y, en cuanto tengamos nombre + dirección + + // descripción, intentamos detectar el gremio y asignar técnico automáticamente + // para que el resto de la conversación siga el flujo normal de agenda de siempre. + // ========================================================= + if (datosExpediente.intake_mode) { + try { + const extraccionCompletion = await openai.chat.completions.create({ + model: OPENAI_MODEL || "gpt-4o-mini", + messages: [ + { + role: "system", + content: "Extrae de esta conversación con un posible cliente los datos que haya dado HASTA AHORA (incluyendo su último mensaje). Responde SOLO un JSON con las claves: nombre, direccion, codigo_postal, poblacion, descripcion. Usa cadena vacía \"\" en cualquier clave que no conste explícitamente en la conversación. No inventes ni deduzcas nada." + }, + ...historialChat, + { role: "user", content: mensajeCliente } + ], + response_format: { type: "json_object" }, + temperature: 0 + }); + + let extraido = {}; + try { + extraido = JSON.parse(extraccionCompletion.choices?.[0]?.message?.content || "{}"); + } catch (eParse) { + extraido = {}; + } + + const patch = {}; + if (typeof extraido.nombre === 'string' && extraido.nombre.trim()) patch["Nombre Cliente"] = extraido.nombre.trim(); + if (typeof extraido.direccion === 'string' && extraido.direccion.trim()) patch["Dirección"] = extraido.direccion.trim(); + if (typeof extraido.codigo_postal === 'string' && extraido.codigo_postal.trim()) patch["Código Postal"] = extraido.codigo_postal.trim(); + if (typeof extraido.poblacion === 'string' && extraido.poblacion.trim()) patch["Población"] = extraido.poblacion.trim(); + if (typeof extraido.descripcion === 'string' && extraido.descripcion.trim()) patch["Descripción"] = extraido.descripcion.trim(); + + const nombreActual = patch["Nombre Cliente"] || datosExpediente.nombre_cliente || ""; + const direccionActual = patch["Dirección"] || datosExpediente.direccion || ""; + const descripcionActual = patch["Descripción"] || datosExpediente.averia || ""; + const capturaCompleta = !!(nombreActual && direccionActual && descripcionActual); + + let workerAsignado = null; + let guildAsignado = null; + + if (capturaCompleta) { + // 🔎 Detección de gremio por palabras clave (misma lógica que la + // asignación automática de servicios importados de proveedores). + const textoLimpio = descripcionActual.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, ""); + const allGuildsQ = await pool.query("SELECT id, name, ia_keywords FROM guilds WHERE owner_id = $1", [ownerId]); + for (const g of allGuildsQ.rows) { + const keywords = Array.isArray(g.ia_keywords) ? g.ia_keywords : []; + if (keywords.some(kw => textoLimpio.includes(String(kw).toLowerCase()))) { + guildAsignado = g.id; + break; + } + } + + // 👷 Solo asignamos técnico si hemos podido detectar el gremio; si no, + // se queda sin gremio y sin operario para que el admin lo revise a mano. + if (guildAsignado) { + let workerQ = await pool.query(` + SELECT u.id FROM users u + JOIN user_guilds ug ON u.id = ug.user_id + WHERE u.owner_id = $1 AND u.status = 'active' AND ug.guild_id = $2 + ORDER BY RANDOM() LIMIT 1 + `, [ownerId, guildAsignado]); + if (workerQ.rowCount === 0) { + workerQ = await pool.query("SELECT id FROM users WHERE owner_id = $1 AND status = 'active' ORDER BY RANDOM() LIMIT 1", [ownerId]); + } + if (workerQ.rowCount > 0) workerAsignado = workerQ.rows[0].id; + } + + patch["intake_mode"] = false; + if (guildAsignado) patch["guild_id"] = guildAsignado; + } + + if (Object.keys(patch).length > 0) { + if (workerAsignado) { + const statusAsignadoQ = await pool.query("SELECT id FROM service_statuses WHERE owner_id=$1 AND name = 'Asignado' LIMIT 1", [ownerId]); + if (statusAsignadoQ.rowCount > 0) patch["status_operativo"] = String(statusAsignadoQ.rows[0].id); + + await pool.query( + `UPDATE scraped_services SET raw_data = COALESCE(raw_data,'{}'::jsonb) || $1::jsonb, assigned_to = $2 WHERE id = $3`, + [JSON.stringify(patch), workerAsignado, datosExpediente.dbId] + ); + } else { + await pool.query( + `UPDATE scraped_services SET raw_data = COALESCE(raw_data,'{}'::jsonb) || $1::jsonb WHERE id = $2`, + [JSON.stringify(patch), datosExpediente.dbId] + ); + } + } + + // Reflejamos los cambios en el objeto en memoria para que el resto de + // esta misma llamada (directiva, huecos de agenda) ya vea los datos frescos. + if (patch["Nombre Cliente"]) datosExpediente.nombre_cliente = patch["Nombre Cliente"]; + if (patch["Dirección"]) datosExpediente.direccion = patch["Dirección"]; + if (patch["Código Postal"]) datosExpediente.cp = patch["Código Postal"]; + if (patch["Población"]) datosExpediente.poblacion = patch["Población"]; + if (patch["Descripción"]) datosExpediente.averia = patch["Descripción"]; + if (workerAsignado) datosExpediente.worker_id = workerAsignado; + if (guildAsignado) datosExpediente.guild_id = guildAsignado; + datosExpediente.intake_mode = !capturaCompleta; + } catch (eIntake) { + console.error("❌ Error en captación de datos (modo intake):", eIntake.message); + } + } + // ========================================================= // HUECOS EXACTOS CALCULADOS POR BACKEND // ========================================================= @@ -2010,7 +2125,16 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { let directivaEstricta = ""; - if (esEstadoFinal) { + if (datosExpediente.intake_mode) { + const faltantes = []; + if (!datosExpediente.nombre_cliente) faltantes.push("su nombre completo"); + if (!datosExpediente.direccion) faltantes.push("la dirección exacta de la visita"); + if (!datosExpediente.averia) faltantes.push(`una breve descripción de lo que necesita${datosExpediente.intake_purpose ? ` (motivo indicado: ${datosExpediente.intake_purpose})` : ''}`); + + directivaEstricta = `🟡 ESTADO ACTUAL: RECOGIDA INICIAL DE DATOS. Esto es una primera toma de contacto (todavía no hay expediente completo), continúa la conversación ya iniciada. +TU OBJETIVO: en un solo mensaje corto, pide de forma natural los datos que aún faltan: ${faltantes.join(", ")}. +⛔ NO ofrezcas fecha ni hora de cita en este mensaje, NO agendes nada todavía: primero necesitamos estos datos básicos.`; + } else 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) { directivaEstricta = `🛑 ESTADO ACTUAL: SIN TÉCNICO ASIGNADO. @@ -5621,7 +5745,11 @@ async function procesarTurnoIA({ ownerId, service, mensajeTexto, telefonoCliente cita_pendiente_fecha: service.cita_pendiente_fecha, cita_pendiente_hora: service.cita_pendiente_hora, compania: service.compania, - averia: service.averia + averia: service.averia, + nombre_cliente: service.nombre_cliente || "", + direccion: service.direccion || "", + intake_mode: service.intake_mode || false, + intake_purpose: service.intake_purpose || "" }); if (!respuestaIA) return; @@ -5632,13 +5760,22 @@ async function procesarTurnoIA({ ownerId, service, mensajeTexto, telefonoCliente .replace(/\n{3,}/g, "\n\n") .trim(); - if (matchPropuesta && service.assigned_to) { + // 🆕 Si el técnico se acaba de auto-asignar en este mismo turno (flujo de + // captación de datos por comando de admin), "service.assigned_to" todavía + // tiene el valor de ANTES de esta llamada — lo refrescamos solo si hace falta. + let assignedToActual = service.assigned_to; + if (!assignedToActual) { + const freshAssignedQ = await pool.query("SELECT assigned_to FROM scraped_services WHERE id=$1", [service.id]); + assignedToActual = freshAssignedQ.rows[0]?.assigned_to || null; + } + + if (matchPropuesta && assignedToActual) { const fechaSugerida = matchPropuesta[1]; const horaSugerida = matchPropuesta[2]; const disponibilidad = await comprobarDisponibilidad( ownerId, - service.assigned_to, + assignedToActual, fechaSugerida, horaSugerida, 60, @@ -5658,6 +5795,22 @@ async function procesarTurnoIA({ ownerId, service, mensajeTexto, telefonoCliente ) WHERE id = $3 `, [fechaSugerida, horaSugerida, service.id]); + + // 🆕 Si este expediente viene del comando de admin ("escribe a..."), le + // avisamos por WhatsApp en cuanto se cierra la cita con el cliente. + if (service.intake_purpose) { + try { + const adminPhoneQ2 = await pool.query("SELECT phone FROM users WHERE id = $1", [ownerId]); + const adminPhone = adminPhoneQ2.rows[0]?.phone; + if (adminPhone) { + const nombreParaAviso = service.nombre_cliente || "el cliente"; + const avisoAdmin = `✅ Cita propuesta con ${nombreParaAviso} (${telefonoCliente}) para el ${fechaSugerida} a las ${horaSugerida}, motivo: ${service.intake_purpose}. Expediente ${service.service_ref}.`; + sendWhatsAppAuto(adminPhone, avisoAdmin, instanceName, false).catch(console.error); + } + } catch (eAvisoAdmin) { + console.error("❌ Error avisando al admin de cita cerrada:", eAvisoAdmin.message); + } + } } } @@ -5676,6 +5829,90 @@ async function procesarTurnoIA({ ownerId, service, mensajeTexto, telefonoCliente } } +// 👑 COMANDOS DE ADMIN POR WHATSAPP: solo se llega aquí si el mensaje viene del móvil +// personal del dueño de la cuenta (ya comprobado en el webhook). De momento entiende +// un único comando: "escribe/manda un mensaje a [teléfono]", que arranca una toma de +// contacto con un cliente nuevo o potencial y va recogiendo sus datos con la IA hasta +// poder ofrecerle cita, igual que con cualquier otro cliente. +async function manejarComandoAdmin({ ownerId, mensajeTexto, esAudioSinTexto, telefonoAdmin, instanceName }) { + if (esAudioSinTexto) { + sendWhatsAppAuto(telefonoAdmin, "🎙️ No puedo escuchar audios, escríbemelo en un mensaje de texto, por favor.", instanceName, false).catch(console.error); + return; + } + + const texto = (mensajeTexto || "").trim(); + if (!texto) return; + + const pending = adminIntakeState.get(ownerId); + + // 2º paso: ya habíamos preguntado el motivo, así que este mensaje ES el motivo. + if (pending && pending.step === 'awaiting_purpose') { + adminIntakeState.delete(ownerId); + const purpose = texto; + + try { + const empresaQ = await pool.query("SELECT full_name FROM users WHERE id=$1", [ownerId]); + const empresaNombre = empresaQ.rows[0]?.full_name || "nuestra empresa"; + + const serviceReference = `M-${Date.now().toString().slice(-6)}`; + const rawData = { + "Nombre Cliente": "", + "Teléfono": pending.targetPhone, + "Dirección": "", + "Descripción": "", + "guild_id": null, + "Compañía": "Particular", + "Origen": "WhatsApp Admin", + "intake_mode": true, + "intake_purpose": purpose + }; + + const insertSvc = await pool.query( + `INSERT INTO scraped_services (owner_id, provider, service_ref, status, automation_status, raw_data) + VALUES ($1, 'MANUAL', $2, 'pending', 'manual', $3) RETURNING id`, + [ownerId, serviceReference, JSON.stringify(rawData)] + ); + const newServiceId = insertSvc.rows[0].id; + + const finalPhone = "34" + pending.targetPhone; + const mensajeApertura = `Hola, tal y como ha hablado con la empresa ${empresaNombre}, le solicitamos algunos datos para poder atenderle (motivo: ${purpose}). ¿Nos podría indicar su nombre completo y la dirección de la visita, por favor?`; + + await sendWhatsAppAuto(finalPhone, mensajeApertura, instanceName, false); + + 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) + `, [newServiceId, ownerId, mensajeApertura]); + + await sendWhatsAppAuto(telefonoAdmin, `✅ Mensaje enviado a ${pending.targetPhone}. En cuanto conteste, sigo yo recogiendo los datos y te aviso cuando quede la cita.`, instanceName, false); + } catch (e) { + console.error("❌ Error iniciando captación de datos por comando de admin:", e.message); + sendWhatsAppAuto(telefonoAdmin, "❌ No he podido iniciar la conversación, revisa el log.", instanceName, false).catch(console.error); + } + return; + } + + // 1er paso: ¿es un comando para iniciar conversación con un número nuevo? + const esComandoInicio = /\b(escribe|escr[íi]bele|manda|mand[aá]le|env[íi]a|env[íi]ale|contacta)\b/i.test(texto); + const telefonoMatch = texto.match(/(\d[\d\s\-]{7,13}\d)/); + let telefonoExtraido = null; + if (telefonoMatch) { + const digits = telefonoMatch[1].replace(/\D/g, ""); + if (digits.length === 9) telefonoExtraido = digits; + else if (digits.length > 9) telefonoExtraido = digits.slice(-9); + } + + if (esComandoInicio && telefonoExtraido) { + adminIntakeState.set(ownerId, { step: 'awaiting_purpose', targetPhone: telefonoExtraido }); + await sendWhatsAppAuto(telefonoAdmin, `De acuerdo. ¿Cuál es el motivo del contacto con el ${telefonoExtraido}? (por ejemplo: avería, presupuesto de visita, u otro)`, instanceName, false); + return; + } + + // No reconocido como comando: no hacemos nada, para no ser un incordio si escribe + // algo sin relación con esto desde su propio móvil. +} + app.post("/webhook/evolution", async (req, res) => { try { const data = req.body; @@ -5693,7 +5930,11 @@ app.post("/webhook/evolution", async (req, res) => { "" ).trim(); - if (!mensajeTexto || !remoteJid || !instanceName) { + // 🎙️ Notas de voz: no las transcribimos (todavía), pero al menos lo detectamos + // para poder avisar en vez de ignorarlo en silencio como pasaba antes. + const esAudioSinTexto = !mensajeTexto && !!data?.data?.message?.audioMessage; + + if ((!mensajeTexto && !esAudioSinTexto) || !remoteJid || !instanceName) { return res.sendStatus(200); } @@ -5717,6 +5958,23 @@ app.post("/webhook/evolution", async (req, res) => { const cleanPhone = telefonoCliente.replace(/\D/g, "").slice(-9); if (!cleanPhone || cleanPhone.length < 9) return; + // 👑 COMANDOS DE ADMINISTRADOR: si quien escribe es el propio dueño de la cuenta + // desde su móvil personal (no el número de WhatsApp Business, sino un contacto + // externo que resulta ser su teléfono registrado), lo tratamos aparte y no sigue + // el flujo normal de cliente/expediente. + if (!fromMe) { + try { + const adminPhoneQ = await pool.query("SELECT phone FROM users WHERE id = $1", [ownerId]); + const adminPhoneClean = (adminPhoneQ.rows[0]?.phone || "").replace(/\D/g, "").slice(-9); + if (adminPhoneClean && adminPhoneClean === cleanPhone) { + await manejarComandoAdmin({ ownerId, mensajeTexto, esAudioSinTexto, telefonoAdmin: telefonoCliente, instanceName }); + return; + } + } catch (eAdmin) { + console.error("❌ Error comprobando comando de admin:", eAdmin.message); + } + } + // 🔍 BUSCAMOS EL EXPEDIENTE ACTIVO MÁS RECIENTE POR TELÉFONO REAL const svcQ = await pool.query(` SELECT @@ -5737,6 +5995,13 @@ 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->>'Nombre Cliente', '') as nombre_cliente, + COALESCE(s.raw_data->>'Dirección', '') as direccion, + COALESCE(s.raw_data->>'intake_purpose', '') as intake_purpose, + CASE + WHEN LOWER(COALESCE(s.raw_data->>'intake_mode', 'false')) IN ('true', 't', '1') THEN true + ELSE false + END as intake_mode, CASE WHEN LOWER(COALESCE(s.raw_data->>'ia_paused', 'false')) IN ('true', 't', '1') THEN true ELSE false @@ -5780,6 +6045,13 @@ app.post("/webhook/evolution", async (req, res) => { const service = svcQ.rows[0]; + // 🎙️ Si el CLIENTE manda un audio, todavía no sabemos transcribirlo: se lo + // decimos directamente en vez de dejarlo sin respuesta como pasaba antes. + if (esAudioSinTexto && !fromMe) { + sendWhatsAppAuto(telefonoCliente, "🎙️ Ahora mismo no puedo escuchar audios, ¿me lo escribes en un mensaje de texto, por favor?", instanceName, false).catch(console.error); + return; + } + // ========================================== // 🧑‍🔧 MENSAJES TUYOS / ADMIN / OFICINA // ==========================================