Subir archivos a "/"

This commit is contained in:
2026-09-12 16:33:07 +00:00
parent c6ab0d9d5a
commit a829f0fd09

282
server.js
View File

@@ -20,6 +20,12 @@ const candadosIA = new Set();
const debounceTimersIA = new Map(); const debounceTimersIA = new Map();
const IA_DEBOUNCE_MS = 8000; 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 // Configuración de CORS Profesional
const corsOptions = { const corsOptions = {
origin: [ origin: [
@@ -1935,6 +1941,115 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
const esPrimerMensaje = historialRows.length <= 1; 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 // HUECOS EXACTOS CALCULADOS POR BACKEND
// ========================================================= // =========================================================
@@ -2010,7 +2125,16 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
let directivaEstricta = ""; 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.`; 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) { } else if (noTieneTecnico) {
directivaEstricta = `🛑 ESTADO ACTUAL: SIN TÉCNICO ASIGNADO. 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_fecha: service.cita_pendiente_fecha,
cita_pendiente_hora: service.cita_pendiente_hora, cita_pendiente_hora: service.cita_pendiente_hora,
compania: service.compania, 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; if (!respuestaIA) return;
@@ -5632,13 +5760,22 @@ async function procesarTurnoIA({ ownerId, service, mensajeTexto, telefonoCliente
.replace(/\n{3,}/g, "\n\n") .replace(/\n{3,}/g, "\n\n")
.trim(); .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 fechaSugerida = matchPropuesta[1];
const horaSugerida = matchPropuesta[2]; const horaSugerida = matchPropuesta[2];
const disponibilidad = await comprobarDisponibilidad( const disponibilidad = await comprobarDisponibilidad(
ownerId, ownerId,
service.assigned_to, assignedToActual,
fechaSugerida, fechaSugerida,
horaSugerida, horaSugerida,
60, 60,
@@ -5658,6 +5795,22 @@ async function procesarTurnoIA({ ownerId, service, mensajeTexto, telefonoCliente
) )
WHERE id = $3 WHERE id = $3
`, [fechaSugerida, horaSugerida, service.id]); `, [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) => { app.post("/webhook/evolution", async (req, res) => {
try { try {
const data = req.body; const data = req.body;
@@ -5693,7 +5930,11 @@ app.post("/webhook/evolution", async (req, res) => {
"" ""
).trim(); ).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); return res.sendStatus(200);
} }
@@ -5717,6 +5958,23 @@ app.post("/webhook/evolution", async (req, res) => {
const cleanPhone = telefonoCliente.replace(/\D/g, "").slice(-9); const cleanPhone = telefonoCliente.replace(/\D/g, "").slice(-9);
if (!cleanPhone || cleanPhone.length < 9) return; 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 // 🔍 BUSCAMOS EL EXPEDIENTE ACTIVO MÁS RECIENTE POR TELÉFONO REAL
const svcQ = await pool.query(` const svcQ = await pool.query(`
SELECT 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->>'requested_time', '') as cita_pendiente_hora,
COALESCE(s.raw_data->>'Compañía', s.raw_data->>'COMPAÑIA', '') as compania, 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->>'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 CASE
WHEN LOWER(COALESCE(s.raw_data->>'ia_paused', 'false')) IN ('true', 't', '1') THEN true WHEN LOWER(COALESCE(s.raw_data->>'ia_paused', 'false')) IN ('true', 't', '1') THEN true
ELSE false ELSE false
@@ -5780,6 +6045,13 @@ app.post("/webhook/evolution", async (req, res) => {
const service = svcQ.rows[0]; 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 // 🧑‍🔧 MENSAJES TUYOS / ADMIN / OFICINA
// ========================================== // ==========================================