Subir archivos a "/"
This commit is contained in:
282
server.js
282
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
|
||||
// ==========================================
|
||||
|
||||
Reference in New Issue
Block a user