badges on the right, evolution api sender
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
createTakeover,
|
||||
listPendingTakeovers,
|
||||
@@ -10,7 +11,9 @@ import {
|
||||
getTakeoverStats,
|
||||
} from "../db/takeoverRepo.js";
|
||||
import { insertAlias, upsertAliasMapping } from "../db/repo.js";
|
||||
import { getRecentMessagesForLLM, getConversationState, upsertConversationState } from "../../2-identity/db/repo.js";
|
||||
import { getRecentMessagesForLLM, getConversationState, upsertConversationState, insertMessage } from "../../2-identity/db/repo.js";
|
||||
import { sseSend } from "../../shared/sse.js";
|
||||
import { sendTextMessage, isEvolutionEnabled } from "../../1-intake/services/evolutionSender.js";
|
||||
|
||||
/**
|
||||
* Lista takeovers pendientes de respuesta
|
||||
@@ -107,12 +110,69 @@ export async function handleRespondToTakeover({
|
||||
id,
|
||||
humanResponse: response,
|
||||
respondedBy,
|
||||
cartItems, // Pasar los items para que se guarden
|
||||
cartItems,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Takeover not found or already responded");
|
||||
}
|
||||
|
||||
// INSERTAR EL MENSAJE DE RESPUESTA EN LA BD
|
||||
if (result.chat_id && response) {
|
||||
try {
|
||||
const messageId = `takeover_${crypto.randomUUID()}`;
|
||||
await insertMessage({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: result.chat_id,
|
||||
provider: "takeover",
|
||||
message_id: messageId,
|
||||
direction: "out",
|
||||
text: response,
|
||||
payload: {
|
||||
reply: response,
|
||||
takeover_id: id,
|
||||
railguard: { simulated: false, source: "human_takeover" }
|
||||
},
|
||||
run_id: null,
|
||||
});
|
||||
|
||||
// Emitir SSE para que la UI se actualice
|
||||
sseSend("run.created", {
|
||||
run_id: null,
|
||||
ts: new Date().toISOString(),
|
||||
chat_id: result.chat_id,
|
||||
from: respondedBy || "admin",
|
||||
status: "ok",
|
||||
prev_state: "AWAITING_HUMAN",
|
||||
input: { text: "[human takeover response]" },
|
||||
llm_output: {
|
||||
reply: response,
|
||||
intent: "human_response",
|
||||
next_state: "CART",
|
||||
},
|
||||
tools: [],
|
||||
invariants: { ok: true, checks: [] },
|
||||
final_reply: response,
|
||||
order_id: null,
|
||||
payment_link: null,
|
||||
latency_ms: 0,
|
||||
});
|
||||
|
||||
// Enviar a WhatsApp via Evolution API (si está habilitado)
|
||||
if (isEvolutionEnabled()) {
|
||||
try {
|
||||
await sendTextMessage({ chatId: result.chat_id, text: response });
|
||||
console.log(`[takeovers] Message sent to Evolution for ${result.chat_id}`);
|
||||
} catch (e) {
|
||||
console.error("[takeovers] Evolution send error:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[takeovers] Message inserted for ${result.chat_id}`);
|
||||
} catch (e) {
|
||||
console.error("[takeovers] Error inserting message:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Si hay items para agregar al carrito, actualizar el estado de la conversación
|
||||
if (cartItems && cartItems.length > 0 && result.chat_id) {
|
||||
@@ -132,7 +192,7 @@ export async function handleRespondToTakeover({
|
||||
}));
|
||||
|
||||
order.cart = [...(order.cart || []), ...newCartItems];
|
||||
|
||||
|
||||
// Actualizar estado: cambiar a CART para que el bot retome
|
||||
await upsertConversationState({
|
||||
tenant_id: tenantId,
|
||||
|
||||
138
src/modules/1-intake/services/evolutionSender.js
Normal file
138
src/modules/1-intake/services/evolutionSender.js
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Evolution API Sender - Envía mensajes a WhatsApp via Evolution API
|
||||
*
|
||||
* Controlado por EVOLUTION_SEND_ENABLED:
|
||||
* - 0 (default): Solo guarda en BD, no envía a WhatsApp (modo prueba)
|
||||
* - 1: Envía realmente a WhatsApp (producción)
|
||||
*/
|
||||
|
||||
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL;
|
||||
const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY;
|
||||
const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_INSTANCE_NAME;
|
||||
|
||||
/**
|
||||
* Verifica si el envío a Evolution API está habilitado
|
||||
*/
|
||||
export function isEvolutionEnabled() {
|
||||
const enabled = process.env.EVOLUTION_SEND_ENABLED;
|
||||
return enabled === "1" || enabled === "true" || enabled === "yes" || enabled === "on";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae el número de teléfono del chat_id
|
||||
* @param {string} chatId - Formato: "5491133230322@s.whatsapp.net"
|
||||
* @returns {string} - Solo el número: "5491133230322"
|
||||
*/
|
||||
function extractPhoneNumber(chatId) {
|
||||
if (!chatId) return null;
|
||||
return chatId.replace("@s.whatsapp.net", "").replace("@c.us", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Envía un mensaje de texto a través de Evolution API
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.chatId - ID del chat (ej: "5491133230322@s.whatsapp.net")
|
||||
* @param {string} params.text - Texto del mensaje a enviar
|
||||
* @param {string} [params.instanceName] - Nombre de la instancia (opcional, usa env por defecto)
|
||||
* @returns {Promise<{ok: boolean, messageId?: string, error?: string}>}
|
||||
*/
|
||||
export async function sendTextMessage({ chatId, text, instanceName = null }) {
|
||||
// Verificar si está habilitado
|
||||
if (!isEvolutionEnabled()) {
|
||||
console.log(`[evolution] Send disabled, message NOT sent to ${chatId}`);
|
||||
return { ok: true, skipped: true, reason: "EVOLUTION_SEND_ENABLED=0" };
|
||||
}
|
||||
|
||||
// Validar configuración
|
||||
if (!EVOLUTION_API_URL || !EVOLUTION_API_KEY) {
|
||||
console.error("[evolution] Missing EVOLUTION_API_URL or EVOLUTION_API_KEY");
|
||||
return { ok: false, error: "missing_config" };
|
||||
}
|
||||
|
||||
const instance = instanceName || EVOLUTION_INSTANCE_NAME;
|
||||
if (!instance) {
|
||||
console.error("[evolution] Missing EVOLUTION_INSTANCE_NAME");
|
||||
return { ok: false, error: "missing_instance" };
|
||||
}
|
||||
|
||||
const phoneNumber = extractPhoneNumber(chatId);
|
||||
if (!phoneNumber) {
|
||||
console.error("[evolution] Invalid chatId:", chatId);
|
||||
return { ok: false, error: "invalid_chat_id" };
|
||||
}
|
||||
|
||||
// Construir URL y payload
|
||||
const url = `${EVOLUTION_API_URL}/message/sendText/${instance}`;
|
||||
const payload = {
|
||||
number: phoneNumber,
|
||||
text: text,
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(`[evolution] Sending to ${phoneNumber}...`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"apikey": EVOLUTION_API_KEY,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[evolution] Error ${response.status}:`, data);
|
||||
return { ok: false, error: `http_${response.status}`, details: data };
|
||||
}
|
||||
|
||||
console.log(`[evolution] Sent to ${phoneNumber}, messageId: ${data?.key?.id || "unknown"}`);
|
||||
return {
|
||||
ok: true,
|
||||
messageId: data?.key?.id || null,
|
||||
response: data,
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
console.error("[evolution] Network error:", err.message);
|
||||
return { ok: false, error: "network_error", message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica el estado de la instancia de Evolution
|
||||
* @returns {Promise<{ok: boolean, connected?: boolean, error?: string}>}
|
||||
*/
|
||||
export async function checkInstanceStatus() {
|
||||
if (!EVOLUTION_API_URL || !EVOLUTION_API_KEY || !EVOLUTION_INSTANCE_NAME) {
|
||||
return { ok: false, error: "missing_config" };
|
||||
}
|
||||
|
||||
const url = `${EVOLUTION_API_URL}/instance/connectionState/${EVOLUTION_INSTANCE_NAME}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"apikey": EVOLUTION_API_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false, error: `http_${response.status}` };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
connected: data?.state === "open" || data?.instance?.state === "open",
|
||||
state: data?.state || data?.instance?.state,
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
return { ok: false, error: "network_error", message: err.message };
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import { runTurnV3 } from "../../3-turn-engine/turnEngineV3.js";
|
||||
import { safeNextState } from "../../3-turn-engine/fsm.js";
|
||||
import { createOrder, updateOrder } from "../../4-woo-orders/wooOrders.js";
|
||||
import { createPreference } from "../../6-mercadopago/mercadoPago.js";
|
||||
import { handleCreateTakeover } from "../../0-ui/handlers/takeovers.js";
|
||||
import { sendTextMessage, isEvolutionEnabled } from "../../1-intake/services/evolutionSender.js";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
@@ -283,6 +285,23 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
||||
actionPatch.woo_order_id = order?.id || null;
|
||||
actionPatch.order_total = calcOrderTotal(order);
|
||||
newTools.push({ type: "update_order", ok: true, order_id: order?.id || null });
|
||||
} else if (act.type === "request_human_takeover") {
|
||||
const takeoverResult = await handleCreateTakeover({
|
||||
tenantId,
|
||||
chatId: chat_id,
|
||||
pendingQuery: act.payload?.pending_query || "consulta pendiente",
|
||||
reason: act.payload?.reason || "product_not_found",
|
||||
contextSnapshot: act.payload?.context_snapshot || null,
|
||||
});
|
||||
newTools.push({ type: "request_human_takeover", ok: takeoverResult?.ok || false, takeover_id: takeoverResult?.takeover?.id || null });
|
||||
// Notificar via SSE para actualizar la campanita inmediatamente
|
||||
if (takeoverResult?.ok) {
|
||||
sseSend("takeover.created", {
|
||||
takeover_id: takeoverResult.takeover?.id,
|
||||
chat_id,
|
||||
pending_query: act.payload?.pending_query,
|
||||
});
|
||||
}
|
||||
} else if (act.type === "send_payment_link") {
|
||||
const total = Number(actionPatch?.order_total || reducedContext?.order_total || 0) || null;
|
||||
if (!total || total <= 0) {
|
||||
@@ -339,6 +358,15 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
||||
});
|
||||
mark("after_insertMessage_out");
|
||||
|
||||
// Enviar a WhatsApp via Evolution API (si está habilitado y el mensaje vino de evolution)
|
||||
if (provider === "evolution" && !isSimulated && isEvolutionEnabled()) {
|
||||
try {
|
||||
await sendTextMessage({ chatId: chat_id, text: plan.reply });
|
||||
} catch (e) {
|
||||
console.error("[pipeline] Evolution send error:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (llmMeta?.error) {
|
||||
const errMsgId = newId("err");
|
||||
await insertMessage({
|
||||
|
||||
@@ -5,10 +5,51 @@
|
||||
import { ConversationState } from "../fsm.js";
|
||||
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
|
||||
|
||||
/**
|
||||
* Detecta si el usuario pregunta por horarios/días de entrega
|
||||
*/
|
||||
function isDeliveryInfoQuestion(text) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
const patterns = [
|
||||
/cu[aá]ndo\s+(entrega|llega|env[ií])/i,
|
||||
/qu[eé]\s+d[ií]as?\s+(entrega|env[ií]|repart)/i,
|
||||
/d[ií]as?\s+de\s+(entrega|env[ií]|reparto)/i,
|
||||
/horario\s+de\s+(entrega|env[ií]|reparto)/i,
|
||||
/qu[eé]\s+horarios?/i,
|
||||
/a\s+qu[eé]\s+hora/i,
|
||||
/en\s+qu[eé]\s+horario/i,
|
||||
/cuando\s+(me\s+)?lo\s+traen/i,
|
||||
/d[ií]a\s+y\s+hora/i,
|
||||
];
|
||||
return patterns.some(p => p.test(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea los días de entrega para mostrar
|
||||
*/
|
||||
function formatDeliveryDays(daysStr) {
|
||||
if (!daysStr) return null;
|
||||
|
||||
const dayMap = {
|
||||
"lun": "Lunes", "mar": "Martes", "mie": "Miércoles", "mié": "Miércoles",
|
||||
"jue": "Jueves", "vie": "Viernes", "sab": "Sábado", "sáb": "Sábado", "dom": "Domingo",
|
||||
};
|
||||
|
||||
const days = daysStr.split(",").map(d => d.trim().toLowerCase());
|
||||
const formatted = days.map(d => dayMap[d] || d).filter(Boolean);
|
||||
|
||||
if (formatted.length === 0) return null;
|
||||
if (formatted.length === 1) return formatted[0];
|
||||
|
||||
// "Lunes, Martes, Miércoles y Jueves"
|
||||
const last = formatted.pop();
|
||||
return `${formatted.join(", ")} y ${last}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja el estado WAITING_WEBHOOKS (esperando confirmación de pago)
|
||||
*/
|
||||
export async function handleWaitingState({ tenantId, text, nlu, order, audit }) {
|
||||
export async function handleWaitingState({ tenantId, text, nlu, order, audit, storeConfig = {} }) {
|
||||
const intent = nlu?.intent || "other";
|
||||
const currentOrder = order || createEmptyOrder();
|
||||
|
||||
@@ -28,6 +69,37 @@ export async function handleWaitingState({ tenantId, text, nlu, order, audit })
|
||||
};
|
||||
}
|
||||
|
||||
// Preguntas sobre horarios/días de entrega
|
||||
if (isDeliveryInfoQuestion(text)) {
|
||||
const deliveryDays = formatDeliveryDays(storeConfig.delivery_days);
|
||||
const startHour = storeConfig.delivery_hours_start?.slice(0, 5);
|
||||
const endHour = storeConfig.delivery_hours_end?.slice(0, 5);
|
||||
|
||||
let reply = "";
|
||||
if (deliveryDays) {
|
||||
reply = `Hacemos entregas los días ${deliveryDays}`;
|
||||
if (startHour && endHour) {
|
||||
reply += ` de ${startHour} a ${endHour}`;
|
||||
}
|
||||
reply += ". ";
|
||||
} else {
|
||||
reply = "Todavía no tengo configurado los días de entrega. ";
|
||||
}
|
||||
|
||||
reply += "Tu pedido ya está en proceso, avisame cualquier cosa.";
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply,
|
||||
next_state: ConversationState.WAITING_WEBHOOKS,
|
||||
intent: "delivery_info",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Default
|
||||
const reply = currentOrder.payment_type === "link"
|
||||
? "Tu pedido está en proceso. Avisame si necesitás algo más o esperá la confirmación de pago."
|
||||
|
||||
@@ -80,13 +80,13 @@ export async function runTurnV3({
|
||||
locale: "es-AR",
|
||||
};
|
||||
|
||||
// Cargar configuración del tenant (se usa en NLU y handlers)
|
||||
const storeConfig = await getStoreConfig({ tenantId });
|
||||
|
||||
let nluResult;
|
||||
|
||||
if (USE_MODULAR_NLU) {
|
||||
// Nuevo sistema NLU modular con prompts editables
|
||||
// Cargar configuración del tenant desde la DB
|
||||
const storeConfig = await getStoreConfig({ tenantId });
|
||||
|
||||
nluResult = await llmNluModular({ input: nluInput, tenantId, storeConfig });
|
||||
audit.nlu = {
|
||||
raw_text: nluResult.raw_text,
|
||||
@@ -123,6 +123,7 @@ export async function runTurnV3({
|
||||
nlu,
|
||||
order,
|
||||
audit,
|
||||
storeConfig,
|
||||
};
|
||||
|
||||
// Regla universal: si quiere agregar productos, volver a CART
|
||||
|
||||
Reference in New Issue
Block a user