badges on the right, evolution api sender
This commit is contained in:
7
.cursor/debug.log
Normal file
7
.cursor/debug.log
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{"location":"pipeline.js:398","message":"upsertConversationState - context being saved","data":{"nextState":"CART","intent":"add_to_cart","orderPendingCount":0,"orderCartCount":1,"pendingItemsFromPatch":0,"pendingQueries":[]},"timestamp":1769399756809,"sessionId":"debug-session","hypothesisId":"A"}
|
||||||
|
{"location":"pipeline.js:398","message":"upsertConversationState - context being saved","data":{"nextState":"CART","intent":"add_to_cart","orderPendingCount":1,"orderCartCount":1,"pendingItemsFromPatch":1,"pendingQueries":["chota"]},"timestamp":1769399763739,"sessionId":"debug-session","hypothesisId":"A"}
|
||||||
|
{"location":"cartHelpers.js:320","message":"takeover - order before/after pending removal","data":{"pendingItemId":"pending_1769399763727_6f1m2r","pendingItemQuery":"chota","originalPendingCount":1,"newPendingCount":0,"originalPendingIds":["pending_1769399763727_6f1m2r"],"newPendingIds":[]},"timestamp":1769399769529,"sessionId":"debug-session","hypothesisId":"C"}
|
||||||
|
{"location":"pipeline.js:398","message":"upsertConversationState - context being saved","data":{"nextState":"CART","intent":"human_takeover","orderPendingCount":0,"orderCartCount":1,"pendingItemsFromPatch":0,"pendingQueries":[]},"timestamp":1769399769547,"sessionId":"debug-session","hypothesisId":"A"}
|
||||||
|
{"location":"takeovers.handler.js:173","message":"respond - state read from DB","data":{"state":"CART","orderPendingCount":0,"orderCartCount":1,"pendingQueries":[]},"timestamp":1769399811578,"sessionId":"debug-session","hypothesisId":"B"}
|
||||||
|
{"location":"takeovers.handler.js:195","message":"respond - order being saved (with cart items)","data":{"orderPendingCount":0,"orderCartCount":2,"pendingQueries":[]},"timestamp":1769399811579,"sessionId":"debug-session","hypothesisId":"B-save"}
|
||||||
|
{"location":"pipeline.js:398","message":"upsertConversationState - context being saved","data":{"nextState":"CART","intent":"view_cart","orderPendingCount":0,"orderCartCount":2,"pendingItemsFromPatch":0,"pendingQueries":[]},"timestamp":1769399826724,"sessionId":"debug-session","hypothesisId":"A"}
|
||||||
19
docs/env.md
19
docs/env.md
@@ -28,6 +28,25 @@
|
|||||||
- **`WOO_CONSUMER_KEY`**: consumer key (se usa solo si en `tenant_ecommerce_config` falta `consumer_key`).
|
- **`WOO_CONSUMER_KEY`**: consumer key (se usa solo si en `tenant_ecommerce_config` falta `consumer_key`).
|
||||||
- **`WOO_CONSUMER_SECRET`**: consumer secret (idem).
|
- **`WOO_CONSUMER_SECRET`**: consumer secret (idem).
|
||||||
|
|
||||||
|
### Evolution API (WhatsApp)
|
||||||
|
|
||||||
|
Variables para el envío de mensajes a WhatsApp via Evolution API.
|
||||||
|
|
||||||
|
- **`EVOLUTION_API_URL`**: URL base de Evolution API (ej: `https://api.evolution.com`).
|
||||||
|
- **`EVOLUTION_API_KEY`**: API key para autenticación (header `apikey`).
|
||||||
|
- **`EVOLUTION_INSTANCE_NAME`**: nombre de la instancia de WhatsApp (ej: `piaf`).
|
||||||
|
- **`EVOLUTION_SEND_ENABLED`**: habilita/deshabilita el envío real a WhatsApp.
|
||||||
|
- `0` (default): Solo guarda mensajes en BD, NO envía a WhatsApp. Ideal para desarrollo/pruebas.
|
||||||
|
- `1`: Envía mensajes realmente a WhatsApp. Para producción.
|
||||||
|
|
||||||
|
**Flujo:**
|
||||||
|
1. Bot recibe mensaje de Evolution via webhook (`/webhook/evolution`)
|
||||||
|
2. Procesa el mensaje y genera respuesta
|
||||||
|
3. Guarda respuesta en BD (`direction: "out"`)
|
||||||
|
4. Si `EVOLUTION_SEND_ENABLED=1`, envía respuesta a Evolution API → WhatsApp
|
||||||
|
|
||||||
|
**Endpoint usado:** `POST {EVOLUTION_API_URL}/message/sendText/{instance}`
|
||||||
|
|
||||||
## Debug por temas (nuevo)
|
## Debug por temas (nuevo)
|
||||||
|
|
||||||
Todos aceptan `1/true/yes/on` para activar.
|
Todos aceptan `1/true/yes/on` para activar.
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ WOO_BASE_URL=https://tu-tienda.com
|
|||||||
WOO_CONSUMER_KEY=ck_xxx
|
WOO_CONSUMER_KEY=ck_xxx
|
||||||
WOO_CONSUMER_SECRET=cs_xxx
|
WOO_CONSUMER_SECRET=cs_xxx
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Evolution API - WhatsApp
|
||||||
|
# ===================
|
||||||
|
EVOLUTION_API_URL=https://api.evolution.com
|
||||||
|
EVOLUTION_API_KEY=your-api-key
|
||||||
|
EVOLUTION_INSTANCE_NAME=piaf
|
||||||
|
EVOLUTION_SEND_ENABLED=0 # 0=solo BD (pruebas), 1=envía a WhatsApp (producción)
|
||||||
|
|
||||||
# ===================
|
# ===================
|
||||||
# Debug Flags (1/true/yes/on para activar)
|
# Debug Flags (1/true/yes/on para activar)
|
||||||
# ===================
|
# ===================
|
||||||
|
|||||||
@@ -29,12 +29,16 @@ class ConversationInspector extends HTMLElement {
|
|||||||
.item.in { background:#0f1520; border-color:#2a3a55; }
|
.item.in { background:#0f1520; border-color:#2a3a55; }
|
||||||
.item.out { background:#111b2a; border-color:#2a3a55; }
|
.item.out { background:#111b2a; border-color:#2a3a55; }
|
||||||
.item.active { outline:2px solid #1f6feb; box-shadow: 0 0 0 2px rgba(31,111,235,.25); }
|
.item.active { outline:2px solid #1f6feb; box-shadow: 0 0 0 2px rgba(31,111,235,.25); }
|
||||||
.kv { display:grid; grid-template-columns:70px 1fr; gap:6px 10px; }
|
.item-row { display:flex; gap:8px; }
|
||||||
.k { color:#8aa0b5; font-size:11px; letter-spacing:.4px; text-transform:uppercase; }
|
.item-left { flex:1; min-width:0; }
|
||||||
.v { font-size:12px; color:#e7eef7; }
|
.item-right { display:flex; flex-direction:column; gap:4px; align-items:flex-end; justify-content:flex-start; min-width:60px; }
|
||||||
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; }
|
.kv { display:grid; grid-template-columns:55px 1fr; gap:4px 6px; }
|
||||||
.chip { display:inline-flex; align-items:center; gap:6px; padding:2px 6px; border-radius:999px; background:#1d2a3a; border:1px solid #243247; font-size:11px; color:#8aa0b5; }
|
.k { color:#8aa0b5; font-size:10px; letter-spacing:.3px; text-transform:uppercase; }
|
||||||
.cart { margin-top:6px; font-size:11px; color:#c7d8ee; }
|
.v { font-size:11px; color:#e7eef7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.chips { display:flex; flex-direction:column; gap:3px; align-items:flex-end; }
|
||||||
|
.chip { display:inline-flex; align-items:center; gap:3px; padding:2px 5px; border-radius:999px; background:#1d2a3a; border:1px solid #243247; font-size:9px; color:#8aa0b5; white-space:nowrap; }
|
||||||
|
.chip .dot { flex-shrink:0; }
|
||||||
|
.cart { margin-top:4px; font-size:10px; color:#c7d8ee; line-height:1.3; }
|
||||||
.tool { margin-top:6px; font-size:11px; color:#8aa0b5; }
|
.tool { margin-top:6px; font-size:11px; color:#8aa0b5; }
|
||||||
.dot { width:8px; height:8px; border-radius:50%; }
|
.dot { width:8px; height:8px; border-radius:50%; }
|
||||||
.ok { background:#2ecc71; } .warn { background:#f1c40f; } .err { background:#e74c3c; }
|
.ok { background:#2ecc71; } .warn { background:#f1c40f; } .err { background:#e74c3c; }
|
||||||
@@ -216,12 +220,26 @@ class ConversationInspector extends HTMLElement {
|
|||||||
|
|
||||||
toolSummary(tools = []) {
|
toolSummary(tools = []) {
|
||||||
return tools.map((t) => ({
|
return tools.map((t) => ({
|
||||||
type: t.type || t.name || "tool",
|
type: this.formatToolName(t.type || t.name || "tool"),
|
||||||
ok: t.ok !== false,
|
ok: t.ok !== false,
|
||||||
error: t.error || null,
|
error: t.error || null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatToolName(name) {
|
||||||
|
// Nombres cortos para tools comunes
|
||||||
|
const shortNames = {
|
||||||
|
"ensure_woo_customer": "woo customer",
|
||||||
|
"create_order": "create order",
|
||||||
|
"update_order": "update order",
|
||||||
|
"send_payment_link": "payment link",
|
||||||
|
"request_human_takeover": "human takeover",
|
||||||
|
"add_to_cart": "add to cart",
|
||||||
|
"human_response_sent": "human response",
|
||||||
|
};
|
||||||
|
return shortNames[name] || name.replace(/_/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const metaEl = this.shadowRoot.getElementById("meta");
|
const metaEl = this.shadowRoot.getElementById("meta");
|
||||||
const countEl = this.shadowRoot.getElementById("count");
|
const countEl = this.shadowRoot.getElementById("count");
|
||||||
@@ -269,24 +287,30 @@ class ConversationInspector extends HTMLElement {
|
|||||||
: "NLU ok";
|
: "NLU ok";
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="kv">
|
<div class="item-row">
|
||||||
<div class="k">${dir === "in" ? "IN" : "OUT"}</div>
|
<div class="item-left">
|
||||||
<div class="v">${new Date(msg.ts).toLocaleString()}</div>
|
<div class="kv">
|
||||||
<div class="k">STATE</div>
|
<div class="k">${dir === "in" ? "IN" : "OUT"}</div>
|
||||||
<div class="v">${dir === "out" ? nextState : prevState}</div>
|
<div class="v">${new Date(msg.ts).toLocaleString()}</div>
|
||||||
<div class="k">INTENT</div>
|
<div class="k">STATE</div>
|
||||||
<div class="v">${dir === "out" ? intent : "—"}</div>
|
<div class="v">${dir === "out" ? nextState : prevState}</div>
|
||||||
<div class="k">NLU</div>
|
<div class="k">INTENT</div>
|
||||||
<div class="v">${dir === "out" && llmMeta ? llmNote : "—"}</div>
|
<div class="v">${dir === "out" ? intent : "—"}</div>
|
||||||
</div>
|
<div class="k">NLU</div>
|
||||||
<div class="cart"><strong>Carrito:</strong> ${dir === "out" ? this.formatOrder(basket, pendingItems, order) : "—"}</div>
|
<div class="v">${dir === "out" && llmMeta ? llmNote : "—"}</div>
|
||||||
<div class="chips">
|
</div>
|
||||||
${tools
|
<div class="cart"><strong>Carrito:</strong> ${dir === "out" ? this.formatOrder(basket, pendingItems, order) : "—"}</div>
|
||||||
.map(
|
</div>
|
||||||
(t) =>
|
<div class="item-right">
|
||||||
`<span class="chip"><span class="dot ${t.ok ? "ok" : "err"}"></span>${t.type}</span>`
|
<div class="chips">
|
||||||
)
|
${tools
|
||||||
.join("")}
|
.map(
|
||||||
|
(t) =>
|
||||||
|
`<span class="chip"><span class="dot ${t.ok ? "ok" : "err"}"></span>${t.type}</span>`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -397,18 +421,24 @@ class ConversationInspector extends HTMLElement {
|
|||||||
el.dataset.messageId = msg.message_id;
|
el.dataset.messageId = msg.message_id;
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="kv">
|
<div class="item-row">
|
||||||
<div class="k">IN</div>
|
<div class="item-left">
|
||||||
<div class="v">${new Date(msg.ts).toLocaleString()}</div>
|
<div class="kv">
|
||||||
<div class="k">STATE</div>
|
<div class="k">IN</div>
|
||||||
<div class="v">—</div>
|
<div class="v">${new Date(msg.ts).toLocaleString()}</div>
|
||||||
<div class="k">INTENT</div>
|
<div class="k">STATE</div>
|
||||||
<div class="v">—</div>
|
<div class="v">—</div>
|
||||||
<div class="k">NLU</div>
|
<div class="k">INTENT</div>
|
||||||
<div class="v">procesando...</div>
|
<div class="v">—</div>
|
||||||
|
<div class="k">NLU</div>
|
||||||
|
<div class="v">procesando...</div>
|
||||||
|
</div>
|
||||||
|
<div class="cart"><strong>Carrito:</strong> —</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-right">
|
||||||
|
<div class="chips"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cart"><strong>Carrito:</strong> —</div>
|
|
||||||
<div class="chips"></div>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ class OpsShell extends HTMLElement {
|
|||||||
this.setView("takeovers", {}, { updateUrl: true });
|
this.setView("takeovers", {}, { updateUrl: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Listen for new takeovers via SSE - update badge immediately
|
||||||
|
this._unsubTakeover = on("takeover:created", () => {
|
||||||
|
this._takeoverCount++;
|
||||||
|
this.updateTakeoverBadge(this._takeoverCount);
|
||||||
|
});
|
||||||
|
|
||||||
// Start polling for takeovers
|
// Start polling for takeovers
|
||||||
this.pollTakeovers();
|
this.pollTakeovers();
|
||||||
this._pollInterval = setInterval(() => this.pollTakeovers(), 30000);
|
this._pollInterval = setInterval(() => this.pollTakeovers(), 30000);
|
||||||
@@ -192,6 +198,7 @@ class OpsShell extends HTMLElement {
|
|||||||
this._unsub?.();
|
this._unsub?.();
|
||||||
this._unsubSwitch?.();
|
this._unsubSwitch?.();
|
||||||
this._unsubRouter?.();
|
this._unsubRouter?.();
|
||||||
|
this._unsubTakeover?.();
|
||||||
if (this._pollInterval) clearInterval(this._pollInterval);
|
if (this._pollInterval) clearInterval(this._pollInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ class TakeoversCrud extends HTMLElement {
|
|||||||
this.loadProducts();
|
this.loadProducts();
|
||||||
|
|
||||||
// Refresh cuando se recibe evento SSE de nuevo takeover
|
// Refresh cuando se recibe evento SSE de nuevo takeover
|
||||||
this._unsubSse = on("sse:takeover", () => this.load());
|
this._unsubSse = on("takeover:created", () => this.load());
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
@@ -298,11 +298,6 @@ class TakeoversCrud extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field" style="flex:1;">
|
|
||||||
<label>Tu respuesta (se enviara como el bot)</label>
|
|
||||||
<textarea id="responseInput" placeholder="Ej: Te anoto 2kg de vacío. ¿Algo más?"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alias-section">
|
<div class="alias-section">
|
||||||
<h4>Agregar Alias (opcional)</h4>
|
<h4>Agregar Alias (opcional)</h4>
|
||||||
<div class="checkbox-row">
|
<div class="checkbox-row">
|
||||||
@@ -320,6 +315,11 @@ class TakeoversCrud extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field" style="flex:1;">
|
||||||
|
<label>Tu respuesta (se enviara como el bot)</label>
|
||||||
|
<textarea id="responseInput" placeholder="Ej: Te anoto 2kg de vacío. ¿Algo más?"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="respondBtn">Enviar Respuesta</button>
|
<button id="respondBtn">Enviar Respuesta</button>
|
||||||
<button id="cancelBtn" class="danger">Cancelar Takeover</button>
|
<button id="cancelBtn" class="danger">Cancelar Takeover</button>
|
||||||
|
|||||||
@@ -268,11 +268,11 @@ export const api = {
|
|||||||
return fetch(`/takeovers/${id}`).then(r => r.json());
|
return fetch(`/takeovers/${id}`).then(r => r.json());
|
||||||
},
|
},
|
||||||
|
|
||||||
async respondTakeover(id, { response, add_alias }) {
|
async respondTakeover(id, { response, add_alias, cart_items }) {
|
||||||
return fetch(`/takeovers/${id}/respond`, {
|
return fetch(`/takeovers/${id}/respond`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ response, add_alias }),
|
body: JSON.stringify({ response, add_alias, cart_items }),
|
||||||
}).then(r => r.json());
|
}).then(r => r.json());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export function connectSSE() {
|
|||||||
es.addEventListener("hello", () => emit("sse:status", { ok: true }));
|
es.addEventListener("hello", () => emit("sse:status", { ok: true }));
|
||||||
es.addEventListener("conversation.upsert", (e) => emit("conversation:upsert", JSON.parse(e.data)));
|
es.addEventListener("conversation.upsert", (e) => emit("conversation:upsert", JSON.parse(e.data)));
|
||||||
es.addEventListener("run.created", (e) => emit("run:created", JSON.parse(e.data)));
|
es.addEventListener("run.created", (e) => emit("run:created", JSON.parse(e.data)));
|
||||||
|
es.addEventListener("takeover.created", (e) => emit("takeover:created", JSON.parse(e.data)));
|
||||||
|
|
||||||
es.onerror = () => emit("sse:status", { ok: false });
|
es.onerror = () => emit("sse:status", { ok: false });
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
import {
|
import {
|
||||||
createTakeover,
|
createTakeover,
|
||||||
listPendingTakeovers,
|
listPendingTakeovers,
|
||||||
@@ -10,7 +11,9 @@ import {
|
|||||||
getTakeoverStats,
|
getTakeoverStats,
|
||||||
} from "../db/takeoverRepo.js";
|
} from "../db/takeoverRepo.js";
|
||||||
import { insertAlias, upsertAliasMapping } from "../db/repo.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
|
* Lista takeovers pendientes de respuesta
|
||||||
@@ -107,12 +110,69 @@ export async function handleRespondToTakeover({
|
|||||||
id,
|
id,
|
||||||
humanResponse: response,
|
humanResponse: response,
|
||||||
respondedBy,
|
respondedBy,
|
||||||
cartItems, // Pasar los items para que se guarden
|
cartItems,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error("Takeover not found or already responded");
|
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
|
// Si hay items para agregar al carrito, actualizar el estado de la conversación
|
||||||
if (cartItems && cartItems.length > 0 && result.chat_id) {
|
if (cartItems && cartItems.length > 0 && result.chat_id) {
|
||||||
@@ -132,7 +192,7 @@ export async function handleRespondToTakeover({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
order.cart = [...(order.cart || []), ...newCartItems];
|
order.cart = [...(order.cart || []), ...newCartItems];
|
||||||
|
|
||||||
// Actualizar estado: cambiar a CART para que el bot retome
|
// Actualizar estado: cambiar a CART para que el bot retome
|
||||||
await upsertConversationState({
|
await upsertConversationState({
|
||||||
tenant_id: tenantId,
|
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 { safeNextState } from "../../3-turn-engine/fsm.js";
|
||||||
import { createOrder, updateOrder } from "../../4-woo-orders/wooOrders.js";
|
import { createOrder, updateOrder } from "../../4-woo-orders/wooOrders.js";
|
||||||
import { createPreference } from "../../6-mercadopago/mercadoPago.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() {
|
function nowIso() {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
@@ -283,6 +285,23 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
|||||||
actionPatch.woo_order_id = order?.id || null;
|
actionPatch.woo_order_id = order?.id || null;
|
||||||
actionPatch.order_total = calcOrderTotal(order);
|
actionPatch.order_total = calcOrderTotal(order);
|
||||||
newTools.push({ type: "update_order", ok: true, order_id: order?.id || null });
|
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") {
|
} else if (act.type === "send_payment_link") {
|
||||||
const total = Number(actionPatch?.order_total || reducedContext?.order_total || 0) || null;
|
const total = Number(actionPatch?.order_total || reducedContext?.order_total || 0) || null;
|
||||||
if (!total || total <= 0) {
|
if (!total || total <= 0) {
|
||||||
@@ -339,6 +358,15 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
|||||||
});
|
});
|
||||||
mark("after_insertMessage_out");
|
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) {
|
if (llmMeta?.error) {
|
||||||
const errMsgId = newId("err");
|
const errMsgId = newId("err");
|
||||||
await insertMessage({
|
await insertMessage({
|
||||||
|
|||||||
@@ -5,10 +5,51 @@
|
|||||||
import { ConversationState } from "../fsm.js";
|
import { ConversationState } from "../fsm.js";
|
||||||
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.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)
|
* 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 intent = nlu?.intent || "other";
|
||||||
const currentOrder = order || createEmptyOrder();
|
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
|
// Default
|
||||||
const reply = currentOrder.payment_type === "link"
|
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."
|
? "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",
|
locale: "es-AR",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cargar configuración del tenant (se usa en NLU y handlers)
|
||||||
|
const storeConfig = await getStoreConfig({ tenantId });
|
||||||
|
|
||||||
let nluResult;
|
let nluResult;
|
||||||
|
|
||||||
if (USE_MODULAR_NLU) {
|
if (USE_MODULAR_NLU) {
|
||||||
// Nuevo sistema NLU modular con prompts editables
|
// 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 });
|
nluResult = await llmNluModular({ input: nluInput, tenantId, storeConfig });
|
||||||
audit.nlu = {
|
audit.nlu = {
|
||||||
raw_text: nluResult.raw_text,
|
raw_text: nluResult.raw_text,
|
||||||
@@ -123,6 +123,7 @@ export async function runTurnV3({
|
|||||||
nlu,
|
nlu,
|
||||||
order,
|
order,
|
||||||
audit,
|
audit,
|
||||||
|
storeConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Regla universal: si quiere agregar productos, volver a CART
|
// Regla universal: si quiere agregar productos, volver a CART
|
||||||
|
|||||||
Reference in New Issue
Block a user