mejoras varias en frontend, separacion de intent y state, pick de articulos
This commit is contained in:
5
index.js
5
index.js
@@ -12,6 +12,7 @@ import { makeEvolutionWebhook } from "./src/controllers/evolution.js";
|
||||
import { makeGetConversationState } from "./src/controllers/conversationState.js";
|
||||
import { makeListMessages } from "./src/controllers/messages.js";
|
||||
import { makeSearchProducts } from "./src/controllers/products.js";
|
||||
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "./src/controllers/admin.js";
|
||||
|
||||
async function configureUndiciDispatcher() {
|
||||
// Node 18+ usa undici debajo de fetch. Esto suele arreglar timeouts “fantasma” por keep-alive/pooling.
|
||||
@@ -77,8 +78,12 @@ app.post("/sim/send", makeSimSend());
|
||||
|
||||
app.get("/conversations", makeGetConversations(() => TENANT_ID));
|
||||
app.get("/conversations/state", makeGetConversationState(() => TENANT_ID));
|
||||
app.delete("/conversations/:chat_id", makeDeleteConversation(() => TENANT_ID));
|
||||
app.post("/conversations/:chat_id/retry-last", makeRetryLast(() => TENANT_ID));
|
||||
app.get("/messages", makeListMessages(() => TENANT_ID));
|
||||
app.get("/products", makeSearchProducts(() => TENANT_ID));
|
||||
app.get("/users", makeListUsers(() => TENANT_ID));
|
||||
app.delete("/users/:chat_id", makeDeleteUser(() => TENANT_ID));
|
||||
|
||||
app.get("/runs", makeListRuns(() => TENANT_ID));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import "./components/ops-shell.js";
|
||||
import "./components/conversation-list.js";
|
||||
import "./components/run-timeline.js";
|
||||
import "./components/chat-simulator.js";
|
||||
import "./components/debug-panel.js";
|
||||
import { connectSSE } from "./lib/sse.js";
|
||||
|
||||
connectSSE();
|
||||
|
||||
@@ -5,6 +5,8 @@ class ChatSimulator extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this._lastPayload = null;
|
||||
this._sending = false;
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display:block; padding:12px; }
|
||||
@@ -15,12 +17,8 @@ class ChatSimulator extends HTMLElement {
|
||||
textarea { width:100%; min-height:70px; resize:vertical; }
|
||||
button { cursor:pointer; }
|
||||
button.primary { background:#1f6feb; border-color:#1f6feb; }
|
||||
.chatlog { display:flex; flex-direction:column; gap:8px; max-height:280px; overflow:auto; padding-right:6px; margin-top:8px; }
|
||||
/* WhatsApp-ish dark theme bubbles */
|
||||
.msg { max-width:90%; padding:8px 10px; border-radius:14px; border:1px solid #253245; font-size:13px; line-height:1.35; white-space:pre-wrap; word-break:break-word; box-shadow: 0 1px 0 rgba(0,0,0,.35); }
|
||||
.msg.user { align-self:flex-end; background:#0f2a1a; border-color:#1f6f43; color:#e7eef7; }
|
||||
.msg.bot { align-self:flex-start; background:#111b2a; border-color:#2a3a55; color:#e7eef7; }
|
||||
pre { white-space:pre-wrap; word-break:break-word; background:#0f1520; border:1px solid #253245; border-radius:10px; padding:10px; margin:0; font-size:12px; color:#d7e2ef; }
|
||||
button:disabled { opacity:.6; cursor:not-allowed; }
|
||||
.status { margin-top:8px; font-size:12px; color:#8aa0b5; }
|
||||
</style>
|
||||
|
||||
<div class="box">
|
||||
@@ -35,19 +33,15 @@ class ChatSimulator extends HTMLElement {
|
||||
<input id="evoTo" style="flex:1" value="5491137887040@s.whatsapp.net" placeholder="to (destino receptor)" />
|
||||
</div>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<input id="pushName" style="flex:1" value="SimUser" placeholder="pushName (opcional)" />
|
||||
<input id="pushName" style="flex:1" value="test_lucas" placeholder="pushName (opcional)" />
|
||||
</div>
|
||||
<div class="muted" style="margin-top:8px">Chat</div>
|
||||
<div class="chatlog" id="log"></div>
|
||||
<textarea id="evoText" placeholder="Texto a enviar por Evolution…"></textarea>
|
||||
<div class="muted" style="margin-top:7px">Enviar mensaje</div>
|
||||
<textarea id="evoText" style="margin-top:8px" placeholder="Texto a enviar por Evolution…"></textarea>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button class="primary" id="sendEvo" style="flex:1">Send</button>
|
||||
<button id="retry" style="width:140px">Retry last</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="muted">Última respuesta (raw)</div>
|
||||
<pre id="raw">—</pre>
|
||||
<div class="status" id="status">—</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -59,8 +53,54 @@ class ChatSimulator extends HTMLElement {
|
||||
const evoPushEl = this.shadowRoot.getElementById("pushName");
|
||||
const evoTextEl = this.shadowRoot.getElementById("evoText");
|
||||
const sendEvoEl = this.shadowRoot.getElementById("sendEvo");
|
||||
const retryEl = this.shadowRoot.getElementById("retry");
|
||||
const statusEl = this.shadowRoot.getElementById("status");
|
||||
|
||||
const sendAction = async () => {
|
||||
const genId = () =>
|
||||
(self.crypto?.randomUUID?.() || `${Date.now()}${Math.random()}`)
|
||||
.replace(/-/g, "")
|
||||
.slice(0, 22)
|
||||
.toUpperCase();
|
||||
|
||||
const buildPayload = ({ text, from, to, instance, pushName }) => {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
body: {
|
||||
event: "messages.upsert",
|
||||
instance,
|
||||
data: {
|
||||
key: {
|
||||
remoteJid: from,
|
||||
fromMe: false,
|
||||
id: genId(),
|
||||
participant: "",
|
||||
addressingMode: "pn",
|
||||
},
|
||||
pushName: pushName || "test_lucas",
|
||||
status: "DELIVERY_ACK",
|
||||
message: { conversation: text },
|
||||
messageType: "conversation",
|
||||
messageTimestamp: nowSec,
|
||||
instanceId: genId(),
|
||||
source: "sim",
|
||||
to,
|
||||
},
|
||||
date_time: new Date().toISOString(),
|
||||
sender: from,
|
||||
server_url: "http://localhost",
|
||||
apikey: "SIM",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const setSending = (v) => {
|
||||
this._sending = Boolean(v);
|
||||
sendEvoEl.disabled = this._sending;
|
||||
retryEl.disabled = this._sending;
|
||||
statusEl.textContent = this._sending ? "Enviando…" : "—";
|
||||
};
|
||||
|
||||
const sendAction = async ({ payloadOverride = null } = {}) => {
|
||||
const instance = evoInstanceEl.value.trim() || "Piaf";
|
||||
const from = evoFromEl.value.trim() || "5491133230322@s.whatsapp.net"; // cliente
|
||||
const to = evoToEl.value.trim() || "5491137887040@s.whatsapp.net"; // canal/destino
|
||||
@@ -72,57 +112,53 @@ class ChatSimulator extends HTMLElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const genId = () =>
|
||||
(self.crypto?.randomUUID?.() || `${Date.now()}${Math.random()}`)
|
||||
.replace(/-/g, "")
|
||||
.slice(0, 22)
|
||||
.toUpperCase();
|
||||
|
||||
const payload = {
|
||||
body: {
|
||||
event: "messages.upsert",
|
||||
instance,
|
||||
data: {
|
||||
key: {
|
||||
// remoteJid debe ser el cliente (buyer)
|
||||
remoteJid: from,
|
||||
fromMe: false,
|
||||
id: genId(),
|
||||
participant: "",
|
||||
addressingMode: "pn",
|
||||
},
|
||||
pushName: pushName || "SimUser",
|
||||
status: "DELIVERY_ACK",
|
||||
message: { conversation: text },
|
||||
messageType: "conversation",
|
||||
messageTimestamp: nowSec,
|
||||
instanceId: genId(),
|
||||
source: "sim",
|
||||
},
|
||||
date_time: new Date().toISOString(),
|
||||
sender: from,
|
||||
server_url: "http://localhost",
|
||||
apikey: "SIM",
|
||||
},
|
||||
};
|
||||
|
||||
const data = await api.simEvolution(payload);
|
||||
this.shadowRoot.getElementById("raw").textContent = JSON.stringify(data, null, 2);
|
||||
console.log("[evolution sim] webhook response:", data);
|
||||
|
||||
if (!data.ok) {
|
||||
this.append("bot", "Error en Evolution Sim.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic: que aparezca en la columna izquierda al instante
|
||||
emit("conversation:upsert", {
|
||||
chat_id: from,
|
||||
from: pushName || "test_lucas",
|
||||
state: "IDLE",
|
||||
intent: "other",
|
||||
status: "ok",
|
||||
last_activity: new Date().toISOString(),
|
||||
last_run_id: null,
|
||||
});
|
||||
emit("ui:selectedChat", { chat_id: from });
|
||||
this.append("user", text);
|
||||
this.append("bot", `[Evolution] enviado (sim): ${text}`);
|
||||
evoTextEl.value = "";
|
||||
|
||||
const payload = payloadOverride || buildPayload({ text, from, to, instance, pushName });
|
||||
this._lastPayload = { ...payload, body: { ...payload.body, data: { ...payload.body.data, key: { ...payload.body.data.key, id: genId() } } } };
|
||||
setSending(true);
|
||||
try {
|
||||
const data = await api.simEvolution(payload);
|
||||
console.log("[evolution sim] webhook response:", data);
|
||||
|
||||
if (!data.ok) {
|
||||
statusEl.textContent = "Error enviando (ver consola)";
|
||||
return;
|
||||
}
|
||||
|
||||
evoTextEl.value = "";
|
||||
evoTextEl.focus();
|
||||
} catch (e) {
|
||||
statusEl.textContent = `Error: ${String(e?.message || e)}`;
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
sendEvoEl.onclick = sendAction;
|
||||
retryEl.onclick = () => {
|
||||
const chat_id = evoFromEl.value.trim() || "5491133230322@s.whatsapp.net";
|
||||
setSending(true);
|
||||
api
|
||||
.retryLast(chat_id)
|
||||
.then((r) => {
|
||||
if (!r?.ok) statusEl.textContent = `Retry error: ${r?.error || "unknown"}`;
|
||||
else statusEl.textContent = "Retry enviado.";
|
||||
})
|
||||
.catch((e) => (statusEl.textContent = `Retry error: ${String(e?.message || e)}`))
|
||||
.finally(() => setSending(false));
|
||||
};
|
||||
retryEl.disabled = false;
|
||||
|
||||
evoTextEl.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
@@ -144,12 +180,8 @@ class ChatSimulator extends HTMLElement {
|
||||
}
|
||||
|
||||
append(who, text) {
|
||||
const log = this.shadowRoot.getElementById("log");
|
||||
const el = document.createElement("div");
|
||||
el.className = "msg " + (who === "user" ? "user" : "bot");
|
||||
el.textContent = text;
|
||||
log.appendChild(el);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
void who;
|
||||
void text;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ class ConversationList extends HTMLElement {
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.conversations = [];
|
||||
this.selected = null;
|
||||
this.tab = "chats"; // chats | users
|
||||
this.users = [];
|
||||
this.selectedUser = null;
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@@ -15,6 +18,11 @@ class ConversationList extends HTMLElement {
|
||||
.row { display:flex; gap:8px; align-items:center; }
|
||||
input,select,button { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px; font-size:13px; }
|
||||
button { cursor:pointer; }
|
||||
button.ghost { background:transparent; }
|
||||
button:disabled { opacity:.6; cursor:not-allowed; }
|
||||
.tabs { display:flex; gap:8px; margin-bottom:10px; }
|
||||
.tab { flex:1; text-align:center; padding:8px; border-radius:8px; border:1px solid #253245; cursor:pointer; color:#8aa0b5; background:#121823; }
|
||||
.tab.active { border-color:#1f6feb; color:#e7eef7; background:#0f1520; }
|
||||
.list { display:flex; flex-direction:column; gap:8px; }
|
||||
.item { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; cursor:pointer; }
|
||||
.item:hover { border-color:#2b3b52; }
|
||||
@@ -25,12 +33,24 @@ class ConversationList extends HTMLElement {
|
||||
.chip { display:inline-flex; align-items:center; gap:6px; padding:3px 8px; border-radius:999px; background:#1d2a3a; border:1px solid #243247; font-size:12px; color:#8aa0b5; }
|
||||
.dot { width:8px; height:8px; border-radius:50%; }
|
||||
.ok{ background:#2ecc71 } .warn{ background:#f1c40f } .err{ background:#e74c3c }
|
||||
.actions { display:flex; gap:8px; justify-content:flex-end; margin-top:8px; flex-wrap:wrap; }
|
||||
</style>
|
||||
|
||||
<div class="box">
|
||||
<div class="tabs">
|
||||
<div class="tab active" id="tabChats">Chats</div>
|
||||
<div class="tab" id="tabUsers">Usuarios</div>
|
||||
</div>
|
||||
|
||||
<div class="box" id="filtersBox">
|
||||
<div class="row">
|
||||
<input id="q" style="flex:1" placeholder="Buscar chat_id / teléfono…" />
|
||||
<button id="refresh">Refresh</button>
|
||||
</div>
|
||||
<div class="box" id="userActions" style="display:none; margin-top:8px">
|
||||
<div class="actions" style="justify-content:flex-start">
|
||||
<button class="ghost" id="uaSelect">Chat</button>
|
||||
<button class="ghost" id="uaDeleteConv">Borrar Chat</button>
|
||||
<button id="uaDeleteUser">Borrar User</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<select id="status" style="flex:1">
|
||||
@@ -51,7 +71,9 @@ class ConversationList extends HTMLElement {
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.shadowRoot.getElementById("refresh").onclick = () => this.refresh();
|
||||
this.shadowRoot.getElementById("tabChats").onclick = () => this.setTab("chats");
|
||||
this.shadowRoot.getElementById("tabUsers").onclick = () => this.setTab("users");
|
||||
|
||||
this.shadowRoot.getElementById("q").oninput = () => {
|
||||
clearTimeout(this._t);
|
||||
this._t = setTimeout(() => this.refresh(), 250);
|
||||
@@ -59,7 +81,31 @@ class ConversationList extends HTMLElement {
|
||||
this.shadowRoot.getElementById("status").onchange = () => this.refresh();
|
||||
this.shadowRoot.getElementById("state").onchange = () => this.refresh();
|
||||
|
||||
// acciones del usuario seleccionado (solo en tab Users)
|
||||
this.shadowRoot.getElementById("uaSelect").onclick = () => {
|
||||
if (!this.selectedUser) return;
|
||||
emit("ui:selectedChat", { chat_id: this.selectedUser.chat_id });
|
||||
this.setTab("chats");
|
||||
};
|
||||
this.shadowRoot.getElementById("uaDeleteConv").onclick = async () => {
|
||||
if (!this.selectedUser) return;
|
||||
const chat_id = this.selectedUser.chat_id;
|
||||
if (!confirm(`¿Borrar conversación completa de ${chat_id}?`)) return;
|
||||
await api.deleteConversation(chat_id);
|
||||
this.selectedUser = null;
|
||||
await this.refreshUsers();
|
||||
};
|
||||
this.shadowRoot.getElementById("uaDeleteUser").onclick = async () => {
|
||||
if (!this.selectedUser) return;
|
||||
const chat_id = this.selectedUser.chat_id;
|
||||
if (!confirm(`¿Borrar usuario ${chat_id}, sus conversaciones y el customer en Woo?`)) return;
|
||||
await api.deleteUser(chat_id, { deleteWoo: true });
|
||||
this.selectedUser = null;
|
||||
await this.refreshUsers();
|
||||
};
|
||||
|
||||
this._unsub1 = on("conversation:upsert", (conv) => {
|
||||
if (this.tab !== "chats") return;
|
||||
const idx = this.conversations.findIndex(x => x.chat_id === conv.chat_id);
|
||||
if (idx >= 0) this.conversations[idx] = conv;
|
||||
else this.conversations.unshift(conv);
|
||||
@@ -79,18 +125,89 @@ class ConversationList extends HTMLElement {
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const q = this.shadowRoot.getElementById("q").value || "";
|
||||
const status = this.shadowRoot.getElementById("status").value || "";
|
||||
const state = this.shadowRoot.getElementById("state").value || "";
|
||||
const data = await api.conversations({ q, status, state });
|
||||
this.conversations = data.items || [];
|
||||
this.render();
|
||||
try {
|
||||
if (this.tab === "users") return await this.refreshUsers();
|
||||
const q = this.shadowRoot.getElementById("q").value || "";
|
||||
const status = this.shadowRoot.getElementById("status").value || "";
|
||||
const state = this.shadowRoot.getElementById("state").value || "";
|
||||
const data = await api.conversations({ q, status, state });
|
||||
this.conversations = data.items || [];
|
||||
this.render();
|
||||
} catch (e) {
|
||||
console.warn("[conversation-list] refresh failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshUsers() {
|
||||
try {
|
||||
const q = this.shadowRoot.getElementById("q").value || "";
|
||||
const data = await api.users({ q, limit: 200 });
|
||||
this.users = data.items || [];
|
||||
this.render();
|
||||
} catch (e) {
|
||||
console.warn("[conversation-list] refreshUsers failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
setTab(tab) {
|
||||
this.tab = tab;
|
||||
this.shadowRoot.getElementById("tabChats").classList.toggle("active", tab === "chats");
|
||||
this.shadowRoot.getElementById("tabUsers").classList.toggle("active", tab === "users");
|
||||
// en users mantenemos el buscador; ocultamos filtros avanzados (status/state)
|
||||
this.shadowRoot.getElementById("filtersBox").style.display = "block";
|
||||
const adv = this.shadowRoot.getElementById("status")?.closest(".row");
|
||||
if (adv) adv.style.display = tab === "chats" ? "flex" : "none";
|
||||
const qEl = this.shadowRoot.getElementById("q");
|
||||
qEl.placeholder = tab === "users" ? "Buscar usuario (chat_id / pushName)…" : "Buscar chat_id / teléfono…";
|
||||
// acciones visibles solo en users (si no hay selección, quedan disabled)
|
||||
this.renderSelectedUserActions();
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
renderSelectedUserActions() {
|
||||
const box = this.shadowRoot.getElementById("userActions");
|
||||
const inUsers = this.tab === "users";
|
||||
box.style.display = inUsers ? "block" : "none";
|
||||
|
||||
const hasSel = Boolean(this.selectedUser);
|
||||
const b1 = this.shadowRoot.getElementById("uaSelect");
|
||||
const b2 = this.shadowRoot.getElementById("uaDeleteConv");
|
||||
const b3 = this.shadowRoot.getElementById("uaDeleteUser");
|
||||
if (b1) b1.disabled = !hasSel;
|
||||
if (b2) b2.disabled = !hasSel;
|
||||
if (b3) b3.disabled = !hasSel;
|
||||
}
|
||||
|
||||
render() {
|
||||
const list = this.shadowRoot.getElementById("list");
|
||||
list.innerHTML = "";
|
||||
|
||||
if (this.tab === "users") {
|
||||
// panel de acciones
|
||||
this.renderSelectedUserActions();
|
||||
|
||||
for (const u of this.users) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "item" + (this.selectedUser?.chat_id === u.chat_id ? " active" : "");
|
||||
const name = u.push_name || u.chat_id.replace(/@.+$/, "");
|
||||
el.innerHTML = `
|
||||
<div class="row">
|
||||
<div style="flex:1">
|
||||
<div class="title">${name}</div>
|
||||
<div class="muted">${u.chat_id}</div>
|
||||
<div class="muted">woo_customer_id: ${u.external_customer_id || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
el.onclick = () => {
|
||||
this.selectedUser = u;
|
||||
this.render();
|
||||
};
|
||||
list.appendChild(el);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const c of this.conversations) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "item" + (c.chat_id === this.selected ? " active" : "");
|
||||
@@ -101,6 +218,7 @@ class ConversationList extends HTMLElement {
|
||||
<div class="title">${c.from}</div>
|
||||
<div class="muted">${c.chat_id}</div>
|
||||
</div>
|
||||
<button class="ghost" data-del="1">Borrar</button>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">state: ${c.state}</span>
|
||||
@@ -108,7 +226,15 @@ class ConversationList extends HTMLElement {
|
||||
<span class="chip">last: ${new Date(c.last_activity).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
`;
|
||||
el.onclick = () => {
|
||||
el.onclick = async (e) => {
|
||||
if (e?.target?.dataset?.del) {
|
||||
e.stopPropagation();
|
||||
if (!confirm(`¿Borrar conversación completa de ${c.chat_id}?`)) return;
|
||||
await api.deleteConversation(c.chat_id);
|
||||
if (this.selected === c.chat_id) this.selected = null;
|
||||
await this.refresh();
|
||||
return;
|
||||
}
|
||||
this.selected = c.chat_id;
|
||||
this.render();
|
||||
emit("ui:selectedChat", { chat_id: c.chat_id });
|
||||
|
||||
71
public/components/debug-panel.js
Normal file
71
public/components/debug-panel.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { api } from "../lib/api.js";
|
||||
import { on } from "../lib/bus.js";
|
||||
|
||||
class DebugPanel extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display:block; padding:12px; }
|
||||
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; }
|
||||
.muted { color:#8aa0b5; font-size:12px; }
|
||||
.kv { display:grid; grid-template-columns:90px 1fr; gap:6px 10px; margin:10px 0 12px; }
|
||||
.k { color:#8aa0b5; font-size:12px; letter-spacing:.4px; text-transform:uppercase; }
|
||||
.v { font-size:13px; font-weight:800; color:#e7eef7; }
|
||||
pre { white-space:pre-wrap; word-break:break-word; background:#0f1520; border:1px solid #253245; border-radius:10px; padding:10px; margin:0; font-size:12px; color:#d7e2ef; }
|
||||
</style>
|
||||
|
||||
<div class="box">
|
||||
<div class="muted">Detalle (click en cualquier burbuja)</div>
|
||||
<div class="kv">
|
||||
<div class="k">INTENT</div><div class="v" id="intent">—</div>
|
||||
<div class="k">STATE</div><div class="v" id="state">—</div>
|
||||
</div>
|
||||
<pre id="out">—</pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
const out = this.shadowRoot.getElementById("out");
|
||||
const intentEl = this.shadowRoot.getElementById("intent");
|
||||
const stateEl = this.shadowRoot.getElementById("state");
|
||||
this._unsub = on("ui:selectedMessage", async ({ message }) => {
|
||||
if (!message) {
|
||||
out.textContent = "—";
|
||||
intentEl.textContent = "—";
|
||||
stateEl.textContent = "—";
|
||||
return;
|
||||
}
|
||||
const base = { message };
|
||||
if (message.run_id) {
|
||||
try {
|
||||
const run = await api.runById(message.run_id);
|
||||
base.run = run;
|
||||
const intent = run?.llm_output?.intent || "—";
|
||||
const state = run?.llm_output?.next_state || "—";
|
||||
intentEl.textContent = intent;
|
||||
stateEl.textContent = state;
|
||||
} catch (e) {
|
||||
base.run_error = String(e?.message || e);
|
||||
intentEl.textContent = "—";
|
||||
stateEl.textContent = "—";
|
||||
}
|
||||
} else {
|
||||
intentEl.textContent = "—";
|
||||
stateEl.textContent = "—";
|
||||
}
|
||||
out.textContent = JSON.stringify(base, null, 2);
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._unsub?.();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("debug-panel", DebugPanel);
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ class OpsShell extends HTMLElement {
|
||||
.layout { flex:1; display:grid; grid-template-columns:320px 1fr 360px; min-height:0; }
|
||||
.col { border-right:1px solid var(--line); min-height:0; overflow:auto; }
|
||||
.col:last-child { border-right:none; }
|
||||
.mid { display:flex; flex-direction:column; min-height:0; }
|
||||
.midTop { flex:1; min-height:0; overflow:auto; border-bottom:1px solid var(--line); }
|
||||
.midBottom { min-height:220px; overflow:auto; }
|
||||
</style>
|
||||
|
||||
<div class="app">
|
||||
@@ -28,8 +31,11 @@ class OpsShell extends HTMLElement {
|
||||
|
||||
<div class="layout">
|
||||
<div class="col"><conversation-list></conversation-list></div>
|
||||
<div class="col"><run-timeline></run-timeline></div>
|
||||
<div class="col"><chat-simulator></chat-simulator></div>
|
||||
<div class="col mid">
|
||||
<div class="midTop"><run-timeline></run-timeline></div>
|
||||
<div class="midBottom"><chat-simulator></chat-simulator></div>
|
||||
</div>
|
||||
<div class="col"><debug-panel></debug-panel></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from "../lib/api.js";
|
||||
import { on } from "../lib/bus.js";
|
||||
import { emit, on } from "../lib/bus.js";
|
||||
|
||||
class RunTimeline extends HTMLElement {
|
||||
constructor() {
|
||||
@@ -7,7 +7,6 @@ class RunTimeline extends HTMLElement {
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.chatId = null;
|
||||
this.items = [];
|
||||
this.selectedDebug = null;
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@@ -22,6 +21,10 @@ class RunTimeline extends HTMLElement {
|
||||
.bubble.user { align-self:flex-end; background:#0f2a1a; border-color:#1f6f43; color:#e7eef7; }
|
||||
.bubble.bot { align-self:flex-start; background:#111b2a; border-color:#2a3a55; color:#e7eef7; }
|
||||
.bubble.err { align-self:flex-start; background:#241214; border-color:#e74c3c; color:#ffe9ea; cursor:pointer; }
|
||||
.name { display:block; font-size:12px; font-weight:800; margin-bottom:4px; opacity:.95; }
|
||||
.bubble.user .name { color:#cdebd8; text-align:right; }
|
||||
.bubble.bot .name { color:#c7d8ee; }
|
||||
.bubble.err .name { color:#ffd0d4; }
|
||||
.bubble .meta { display:block; margin-top:6px; font-size:11px; color:#8aa0b5; }
|
||||
.bubble.user .meta { color:#b9d9c6; opacity:.85; }
|
||||
.bubble.bot .meta { color:#a9bed6; opacity:.85; }
|
||||
@@ -42,11 +45,6 @@ class RunTimeline extends HTMLElement {
|
||||
<div class="chatlog" id="log"></div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="muted">Debug (click en burbuja roja para ver detalles)</div>
|
||||
<pre id="debug">—</pre>
|
||||
</div>
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -79,13 +77,20 @@ class RunTimeline extends HTMLElement {
|
||||
if (!this.chatId) {
|
||||
this.shadowRoot.getElementById("meta").textContent = "Seleccioná una conversación.";
|
||||
this.shadowRoot.getElementById("log").innerHTML = "";
|
||||
this.shadowRoot.getElementById("debug").textContent = "—";
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await api.messages({ chat_id: this.chatId, limit: 200 });
|
||||
this.items = data.items || [];
|
||||
this.render();
|
||||
try {
|
||||
const data = await api.messages({ chat_id: this.chatId, limit: 200 });
|
||||
this.items = data.items || [];
|
||||
this.render();
|
||||
} catch (e) {
|
||||
this.items = [];
|
||||
this.shadowRoot.getElementById("meta").textContent = `Error cargando mensajes: ${String(
|
||||
e?.message || e
|
||||
)}`;
|
||||
this.shadowRoot.getElementById("log").innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
isErrorMsg(m) {
|
||||
@@ -93,11 +98,19 @@ class RunTimeline extends HTMLElement {
|
||||
return Boolean(m?.payload?.error) || txt.startsWith("[ERROR]") || txt.toLowerCase().includes("internal_error");
|
||||
}
|
||||
|
||||
displayNameFor(m) {
|
||||
// Inbound: usar pushName si vino del webhook; fallback al "from" (teléfono) si existe.
|
||||
const pushName = m?.payload?.raw?.meta?.pushName || m?.payload?.raw?.meta?.pushname || null;
|
||||
const from = m?.payload?.raw?.from || null;
|
||||
if (m?.direction === "in") return pushName || from || "test_lucas";
|
||||
// Outbound: nombre del bot
|
||||
return "Piaf";
|
||||
}
|
||||
|
||||
render() {
|
||||
const meta = this.shadowRoot.getElementById("meta");
|
||||
const count = this.shadowRoot.getElementById("count");
|
||||
const log = this.shadowRoot.getElementById("log");
|
||||
const dbg = this.shadowRoot.getElementById("debug");
|
||||
|
||||
meta.textContent = `Mostrando historial (últimos ${this.items.length}).`;
|
||||
count.textContent = this.items.length ? `${this.items.length} msgs` : "";
|
||||
@@ -109,19 +122,23 @@ class RunTimeline extends HTMLElement {
|
||||
const isErr = this.isErrorMsg(m);
|
||||
const bubble = document.createElement("div");
|
||||
bubble.className = `bubble ${isErr ? "err" : who}`;
|
||||
bubble.textContent = m.text || (isErr ? "Error" : "—");
|
||||
|
||||
const nameEl = document.createElement("span");
|
||||
nameEl.className = "name";
|
||||
nameEl.textContent = this.displayNameFor(m);
|
||||
bubble.appendChild(nameEl);
|
||||
|
||||
const textEl = document.createElement("div");
|
||||
textEl.textContent = m.text || (isErr ? "Error" : "—");
|
||||
bubble.appendChild(textEl);
|
||||
|
||||
const metaEl = document.createElement("span");
|
||||
metaEl.className = "meta";
|
||||
metaEl.textContent = `${new Date(m.ts).toLocaleString()} • ${m.provider} • ${m.message_id}`;
|
||||
bubble.appendChild(metaEl);
|
||||
|
||||
if (isErr) {
|
||||
bubble.title = "Click para ver detalles (JSON)";
|
||||
bubble.onclick = () => {
|
||||
dbg.textContent = JSON.stringify(m, null, 2);
|
||||
};
|
||||
}
|
||||
bubble.title = "Click para ver detalles (JSON)";
|
||||
bubble.onclick = () => emit("ui:selectedMessage", { message: m });
|
||||
|
||||
log.appendChild(bubble);
|
||||
}
|
||||
@@ -129,7 +146,6 @@ class RunTimeline extends HTMLElement {
|
||||
// auto-scroll
|
||||
log.scrollTop = log.scrollHeight;
|
||||
|
||||
if (!this.items.length) dbg.textContent = "—";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,25 @@ export const api = {
|
||||
return fetch(u).then(r => r.json());
|
||||
},
|
||||
|
||||
async deleteConversation(chat_id) {
|
||||
if (!chat_id) throw new Error("chat_id_required");
|
||||
return fetch(`/conversations/${encodeURIComponent(chat_id)}`, { method: "DELETE" }).then(r => r.json());
|
||||
},
|
||||
|
||||
async users({ q = "", limit = 200 } = {}) {
|
||||
const u = new URL("/users", location.origin);
|
||||
if (q) u.searchParams.set("q", String(q));
|
||||
u.searchParams.set("limit", String(limit));
|
||||
return fetch(u).then(r => r.json());
|
||||
},
|
||||
|
||||
async deleteUser(chat_id, { deleteWoo = false } = {}) {
|
||||
if (!chat_id) throw new Error("chat_id_required");
|
||||
const u = new URL(`/users/${encodeURIComponent(chat_id)}`, location.origin);
|
||||
if (deleteWoo) u.searchParams.set("deleteWoo", "1");
|
||||
return fetch(u, { method: "DELETE" }).then(r => r.json());
|
||||
},
|
||||
|
||||
async messages({ chat_id, limit = 200 } = {}) {
|
||||
const u = new URL("/messages", location.origin);
|
||||
if (chat_id) u.searchParams.set("chat_id", chat_id);
|
||||
@@ -21,6 +40,11 @@ export const api = {
|
||||
return fetch(u).then(r => r.json());
|
||||
},
|
||||
|
||||
async runById(run_id) {
|
||||
if (!run_id) return null;
|
||||
return fetch(`/runs/${encodeURIComponent(run_id)}`).then(r => r.json());
|
||||
},
|
||||
|
||||
async simEvolution(payload) {
|
||||
return fetch("/webhook/evolution", {
|
||||
method: "POST",
|
||||
@@ -28,4 +52,9 @@ export const api = {
|
||||
body: JSON.stringify(payload),
|
||||
}).then(r => r.json());
|
||||
},
|
||||
|
||||
async retryLast(chat_id) {
|
||||
if (!chat_id) throw new Error("chat_id_required");
|
||||
return fetch(`/conversations/${encodeURIComponent(chat_id)}/retry-last`, { method: "POST" }).then(r => r.json());
|
||||
},
|
||||
};
|
||||
|
||||
48
src/controllers/admin.js
Normal file
48
src/controllers/admin.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { handleDeleteConversation, handleDeleteUser, handleListUsers, handleRetryLast } from "../handlers/admin.js";
|
||||
|
||||
export const makeDeleteConversation = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const result = await handleDeleteConversation({ tenantId, chat_id: req.params.chat_id });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ ok: false, error: "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeListUsers = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const result = await handleListUsers({ tenantId, q: req.query.q || "", limit: req.query.limit || "200" });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ ok: false, error: "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeDeleteUser = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const deleteWoo = String(req.query.deleteWoo || "0") === "1" || String(req.query.deleteWoo || "").toLowerCase() === "true";
|
||||
const result = await handleDeleteUser({ tenantId, chat_id: req.params.chat_id, deleteWoo });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ ok: false, error: "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeRetryLast = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const result = await handleRetryLast({ tenantId, chat_id: req.params.chat_id });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ ok: false, error: "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
156
src/db/repo.js
156
src/db/repo.js
@@ -55,6 +55,22 @@ export async function upsertConversationState({
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Crea la conversación si no existe y, si existe, solo “toca” updated_at (no pisa state/context).
|
||||
export async function touchConversationState({ tenant_id, wa_chat_id }) {
|
||||
const q = `
|
||||
insert into wa_conversation_state
|
||||
(tenant_id, wa_chat_id, state, state_updated_at, last_intent, last_order_id, context, updated_at)
|
||||
values
|
||||
($1, $2, 'IDLE', now(), 'other', null, '{}'::jsonb, now())
|
||||
on conflict (tenant_id, wa_chat_id)
|
||||
do update set
|
||||
updated_at = now()
|
||||
returning tenant_id, wa_chat_id, state, last_intent, context, updated_at
|
||||
`;
|
||||
const { rows } = await pool.query(q, [tenant_id, wa_chat_id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function insertMessage({
|
||||
tenant_id,
|
||||
wa_chat_id,
|
||||
@@ -299,6 +315,146 @@ export async function listMessages({ tenant_id, wa_chat_id, limit = 200 }) {
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteConversationData({ tenant_id, wa_chat_id }) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const r1 = await client.query(`delete from wa_messages where tenant_id=$1 and wa_chat_id=$2`, [
|
||||
tenant_id,
|
||||
wa_chat_id,
|
||||
]);
|
||||
const r2 = await client.query(`delete from conversation_runs where tenant_id=$1 and wa_chat_id=$2`, [
|
||||
tenant_id,
|
||||
wa_chat_id,
|
||||
]);
|
||||
const r3 = await client.query(`delete from wa_conversation_state where tenant_id=$1 and wa_chat_id=$2`, [
|
||||
tenant_id,
|
||||
wa_chat_id,
|
||||
]);
|
||||
|
||||
await client.query("COMMIT");
|
||||
return { ok: true, deleted: { messages: r1.rowCount, runs: r2.rowCount, state: r3.rowCount } };
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listUsers({ tenant_id, q = "", limit = 200 }) {
|
||||
const lim = Math.max(1, Math.min(500, parseInt(limit, 10) || 200));
|
||||
const qstr = String(q || "").trim();
|
||||
|
||||
// Lista de “usuarios” = conversaciones existentes (wa_conversation_state), con join al mapping Woo si existe.
|
||||
// Esto permite ver usuarios aunque nunca se haya creado el customer en Woo.
|
||||
const sql = `
|
||||
select *
|
||||
from (
|
||||
select s.wa_chat_id,
|
||||
'woo' as provider,
|
||||
m.external_customer_id,
|
||||
lastmsg.ts as last_ts,
|
||||
nullif(coalesce(lastmsg.payload #>> '{raw,meta,pushName}', lastmsg.payload #>> '{raw,meta,pushname}', ''), '') as push_name
|
||||
from wa_conversation_state s
|
||||
left join wa_identity_map m
|
||||
on m.tenant_id = s.tenant_id
|
||||
and m.wa_chat_id = s.wa_chat_id
|
||||
and m.provider = 'woo'
|
||||
left join lateral (
|
||||
select ts, payload
|
||||
from wa_messages
|
||||
where tenant_id = s.tenant_id
|
||||
and wa_chat_id = s.wa_chat_id
|
||||
and direction = 'in'
|
||||
order by ts desc
|
||||
limit 1
|
||||
) lastmsg on true
|
||||
where s.tenant_id = $1
|
||||
) t
|
||||
where ($2 = '' or t.wa_chat_id ilike $3 or coalesce(t.push_name,'') ilike $3)
|
||||
order by coalesce(t.last_ts, now()) desc
|
||||
limit $4
|
||||
`;
|
||||
|
||||
const like = qstr ? `%${qstr}%` : "";
|
||||
const { rows } = await pool.query(sql, [tenant_id, qstr, like, lim]);
|
||||
return rows.map((r) => ({
|
||||
chat_id: r.wa_chat_id,
|
||||
provider: r.provider,
|
||||
external_customer_id: r.external_customer_id,
|
||||
push_name: r.push_name || null,
|
||||
last_ts: r.last_ts || null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getLastInboundMessage({ tenant_id, wa_chat_id }) {
|
||||
const q = `
|
||||
select provider, message_id, ts, text, payload
|
||||
from wa_messages
|
||||
where tenant_id=$1 and wa_chat_id=$2 and direction='in'
|
||||
order by ts desc
|
||||
limit 1
|
||||
`;
|
||||
const { rows } = await pool.query(q, [tenant_id, wa_chat_id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function cleanupLastRunForRetry({ tenant_id, wa_chat_id }) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const { rows } = await client.query(
|
||||
`
|
||||
select id
|
||||
from conversation_runs
|
||||
where tenant_id=$1 and wa_chat_id=$2
|
||||
order by ts desc
|
||||
limit 1
|
||||
`,
|
||||
[tenant_id, wa_chat_id]
|
||||
);
|
||||
const run_id = rows[0]?.id || null;
|
||||
if (!run_id) {
|
||||
await client.query("COMMIT");
|
||||
return { ok: true, run_id: null, deleted_out_messages: 0, deleted_runs: 0 };
|
||||
}
|
||||
|
||||
const r1 = await client.query(
|
||||
`delete from wa_messages where tenant_id=$1 and wa_chat_id=$2 and run_id=$3 and direction='out'`,
|
||||
[tenant_id, wa_chat_id, run_id]
|
||||
);
|
||||
const r2 = await client.query(`delete from conversation_runs where tenant_id=$1 and id=$2`, [tenant_id, run_id]);
|
||||
|
||||
await client.query("COMMIT");
|
||||
return { ok: true, run_id, deleted_out_messages: r1.rowCount || 0, deleted_runs: r2.rowCount || 0 };
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getIdentityMapByChat({ tenant_id, wa_chat_id, provider = "woo" }) {
|
||||
const q = `
|
||||
select tenant_id, wa_chat_id, provider, external_customer_id, created_at, updated_at
|
||||
from wa_identity_map
|
||||
where tenant_id=$1 and wa_chat_id=$2 and provider=$3
|
||||
limit 1
|
||||
`;
|
||||
const { rows } = await pool.query(q, [tenant_id, wa_chat_id, provider]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function deleteIdentityMapByChat({ tenant_id, wa_chat_id, provider = "woo" }) {
|
||||
const q = `delete from wa_identity_map where tenant_id=$1 and wa_chat_id=$2 and provider=$3`;
|
||||
const { rowCount } = await pool.query(q, [tenant_id, wa_chat_id, provider]);
|
||||
return rowCount || 0;
|
||||
}
|
||||
|
||||
export async function getTenantByKey(key) {
|
||||
const { rows } = await pool.query(`select id, key, name from tenants where key=$1`, [key]);
|
||||
return rows[0] || null;
|
||||
|
||||
91
src/handlers/admin.js
Normal file
91
src/handlers/admin.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
cleanupLastRunForRetry,
|
||||
deleteConversationData,
|
||||
deleteIdentityMapByChat,
|
||||
getIdentityMapByChat,
|
||||
getLastInboundMessage,
|
||||
listUsers,
|
||||
} from "../db/repo.js";
|
||||
import { deleteWooCustomer } from "../services/woo.js";
|
||||
import { processMessage } from "../services/pipeline.js";
|
||||
|
||||
export async function handleDeleteConversation({ tenantId, chat_id }) {
|
||||
if (!chat_id) return { ok: false, error: "chat_id_required" };
|
||||
const result = await deleteConversationData({ tenant_id: tenantId, wa_chat_id: String(chat_id) });
|
||||
return { ok: true, ...result };
|
||||
}
|
||||
|
||||
export async function handleListUsers({ tenantId, q = "", limit = "200" }) {
|
||||
const items = await listUsers({ tenant_id: tenantId, q: String(q || ""), limit: parseInt(limit, 10) || 200 });
|
||||
return { ok: true, items };
|
||||
}
|
||||
|
||||
export async function handleDeleteUser({ tenantId, chat_id, deleteWoo = false }) {
|
||||
const wa_chat_id = String(chat_id || "");
|
||||
if (!wa_chat_id) return { ok: false, error: "chat_id_required" };
|
||||
|
||||
const mapping = await getIdentityMapByChat({ tenant_id: tenantId, wa_chat_id, provider: "woo" });
|
||||
const external_customer_id = mapping?.external_customer_id || null;
|
||||
|
||||
// borrar conversaciones (mensajes + runs + state)
|
||||
const convo = await deleteConversationData({ tenant_id: tenantId, wa_chat_id });
|
||||
|
||||
// borrar mapping
|
||||
const deletedMap = await deleteIdentityMapByChat({ tenant_id: tenantId, wa_chat_id, provider: "woo" });
|
||||
|
||||
// borrar customer en woo (best-effort)
|
||||
let woo = null;
|
||||
if (deleteWoo && external_customer_id) {
|
||||
try {
|
||||
woo = await deleteWooCustomer({ tenantId, id: external_customer_id, force: true });
|
||||
} catch (e) {
|
||||
woo = { ok: false, error: String(e?.message || e), status: e?.status || e?.cause?.status || null };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
chat_id: wa_chat_id,
|
||||
external_customer_id,
|
||||
deleted: {
|
||||
conversations: convo?.deleted || null,
|
||||
identity_map: deletedMap,
|
||||
woo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleRetryLast({ tenantId, chat_id }) {
|
||||
const wa_chat_id = String(chat_id || "");
|
||||
if (!wa_chat_id) return { ok: false, error: "chat_id_required" };
|
||||
|
||||
const lastIn = await getLastInboundMessage({ tenant_id: tenantId, wa_chat_id });
|
||||
if (!lastIn) return { ok: false, error: "no_inbound_message" };
|
||||
|
||||
// 1) borrar la última respuesta/run
|
||||
const cleanup = await cleanupLastRunForRetry({ tenant_id: tenantId, wa_chat_id });
|
||||
|
||||
// 2) reinyectar el último mensaje del usuario como un nuevo message_id
|
||||
const raw = lastIn.payload?.raw || {};
|
||||
const text = String(lastIn.text || raw.text || "").trim();
|
||||
if (!text) return { ok: false, error: "last_inbound_text_empty", cleanup };
|
||||
|
||||
const from = String(raw.from || wa_chat_id.replace(/@.+$/, ""));
|
||||
const pushName = raw?.meta?.pushName || raw?.meta?.pushname || null;
|
||||
|
||||
const pm = await processMessage({
|
||||
tenantId,
|
||||
chat_id: wa_chat_id,
|
||||
from,
|
||||
text,
|
||||
provider: lastIn.provider || "evolution",
|
||||
message_id: crypto.randomUUID(),
|
||||
displayName: pushName,
|
||||
meta: { ...(raw.meta || {}), source: "retry_last" },
|
||||
});
|
||||
|
||||
return { ok: true, cleanup, run_id: pm?.run_id || null };
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function handleEvolutionWebhook(body) {
|
||||
text: parsed.text,
|
||||
provider: "evolution",
|
||||
message_id: parsed.message_id || crypto.randomUUID(),
|
||||
meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key },
|
||||
meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key, source: parsed.source },
|
||||
});
|
||||
|
||||
console.log("[perf] evolution.webhook.end", {
|
||||
|
||||
@@ -40,6 +40,7 @@ export function parseEvolutionWebhook(reqBody) {
|
||||
// metadata
|
||||
const pushName = data.pushName || null;
|
||||
const ts = data.messageTimestamp ? new Date(Number(data.messageTimestamp) * 1000).toISOString() : null;
|
||||
const source = data.source || null; // e.g. "sim"
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -50,6 +51,7 @@ export function parseEvolutionWebhook(reqBody) {
|
||||
from_name: pushName,
|
||||
message_type: messageType || null,
|
||||
ts,
|
||||
source,
|
||||
raw: body, // para log/debug si querés
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY;
|
||||
let _client = null;
|
||||
let _clientKey = null;
|
||||
|
||||
export const openai = new OpenAI({ apiKey });
|
||||
function getApiKey() {
|
||||
return process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY || null;
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
const err = new Error("OPENAI_API_KEY is not set");
|
||||
err.code = "OPENAI_NO_KEY";
|
||||
throw err;
|
||||
}
|
||||
if (_client && _clientKey === apiKey) return _client;
|
||||
_clientKey = apiKey;
|
||||
_client = new OpenAI({ apiKey });
|
||||
return _client;
|
||||
}
|
||||
|
||||
const NextStateSchema = z.enum([
|
||||
"IDLE",
|
||||
@@ -53,6 +69,19 @@ const PlanSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ExtractItemSchema = z.object({
|
||||
label: z.string().min(1),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.enum(["kg", "g", "unit"]),
|
||||
});
|
||||
|
||||
const ExtractSchema = z
|
||||
.object({
|
||||
intent: IntentSchema,
|
||||
items: z.array(ExtractItemSchema).default([]),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function extractJsonObject(text) {
|
||||
const s = String(text || "");
|
||||
const i = s.indexOf("{");
|
||||
@@ -61,36 +90,29 @@ function extractJsonObject(text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un "plan" de conversación (salida estructurada) usando OpenAI.
|
||||
*
|
||||
* - `promptSystem`: instrucciones del bot
|
||||
* - `input`: { last_user_message, conversation_history, current_conversation_state, context }
|
||||
*/
|
||||
export async function llmPlan({ promptSystem, input, model } = {}) {
|
||||
if (!apiKey) {
|
||||
const err = new Error("OPENAI_API_KEY is not set");
|
||||
err.code = "OPENAI_NO_KEY";
|
||||
throw err;
|
||||
}
|
||||
|
||||
async function jsonCompletion({ system, user, model }) {
|
||||
const openai = getClient();
|
||||
const chosenModel = model || process.env.OPENAI_MODEL || "gpt-4o-mini";
|
||||
const debug = String(process.env.LLM_DEBUG || "") === "1";
|
||||
if (debug) console.log("[llm] openai.request", { model: chosenModel });
|
||||
|
||||
const resp = await openai.chat.completions.create({
|
||||
model: chosenModel,
|
||||
temperature: 0.2,
|
||||
response_format: { type: "json_object" },
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
`${promptSystem}\n\n` +
|
||||
"Respondé SOLO con un JSON válido (sin markdown). Respetá estrictamente el formato requerido.",
|
||||
},
|
||||
{ role: "user", content: JSON.stringify(input ?? {}) },
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
],
|
||||
});
|
||||
|
||||
if (debug)
|
||||
console.log("[llm] openai.response", {
|
||||
id: resp?.id || null,
|
||||
model: resp?.model || null,
|
||||
usage: resp?.usage || null,
|
||||
});
|
||||
|
||||
const text = resp?.choices?.[0]?.message?.content || "";
|
||||
let parsed;
|
||||
try {
|
||||
@@ -100,12 +122,51 @@ export async function llmPlan({ promptSystem, input, model } = {}) {
|
||||
if (!extracted) throw new Error("openai_invalid_json");
|
||||
parsed = JSON.parse(extracted);
|
||||
}
|
||||
return { parsed, raw_text: text, model: chosenModel, usage: resp?.usage || null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un "plan" de conversación (salida estructurada) usando OpenAI.
|
||||
*
|
||||
* - `promptSystem`: instrucciones del bot
|
||||
* - `input`: { last_user_message, conversation_history, current_conversation_state, context }
|
||||
*/
|
||||
export async function llmPlan({ promptSystem, input, model } = {}) {
|
||||
const system =
|
||||
`${promptSystem}\n\n` +
|
||||
"Respondé SOLO con un JSON válido (sin markdown). Respetá estrictamente el formato requerido.";
|
||||
const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({
|
||||
system,
|
||||
user: JSON.stringify(input ?? {}),
|
||||
model,
|
||||
});
|
||||
|
||||
const plan = PlanSchema.parse(parsed);
|
||||
return {
|
||||
plan,
|
||||
raw_text: text,
|
||||
raw_text,
|
||||
model: chosenModel,
|
||||
usage: resp?.usage || null,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Paso 1: extracción de intención + items mencionados (sin resolver IDs).
|
||||
* Devuelve SOLO: intent + items[{label, quantity, unit}]
|
||||
*/
|
||||
export async function llmExtract({ input, model } = {}) {
|
||||
const system =
|
||||
"Extraé intención e items del mensaje del usuario.\n" +
|
||||
"Respondé SOLO JSON válido (sin markdown) con keys EXACTAS:\n" +
|
||||
`intent (one of: ${IntentSchema.options.join("|")}), items (array of {label, quantity, unit(kg|g|unit)}).\n` +
|
||||
"Si no hay items claros, devolvé items: [].";
|
||||
|
||||
const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({
|
||||
system,
|
||||
user: JSON.stringify(input ?? {}),
|
||||
model,
|
||||
});
|
||||
|
||||
const extracted = ExtractSchema.parse(parsed);
|
||||
return { extracted, raw_text, model: chosenModel, usage };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -377,3 +377,42 @@ export async function getWooCustomerById({ tenantId, id }) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWooCustomer({ tenantId, id, force = true }) {
|
||||
if (!id) return { ok: false, error: "missing_id" };
|
||||
const encryptionKey = process.env.APP_ENCRYPTION_KEY;
|
||||
if (!encryptionKey) throw new Error("APP_ENCRYPTION_KEY is required to decrypt Woo credentials");
|
||||
|
||||
const cfg = await getDecryptedTenantEcommerceConfig({
|
||||
tenant_id: tenantId,
|
||||
provider: "woo",
|
||||
encryption_key: encryptionKey,
|
||||
});
|
||||
if (!cfg) throw new Error("Woo config not found for tenant");
|
||||
|
||||
const consumerKey =
|
||||
cfg.consumer_key ||
|
||||
process.env.WOO_CONSUMER_KEY ||
|
||||
(() => {
|
||||
throw new Error("consumer_key not set");
|
||||
})();
|
||||
const consumerSecret =
|
||||
cfg.consumer_secret ||
|
||||
process.env.WOO_CONSUMER_SECRET ||
|
||||
(() => {
|
||||
throw new Error("consumer_secret not set");
|
||||
})();
|
||||
|
||||
const base = cfg.base_url.replace(/\/+$/, "");
|
||||
const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64");
|
||||
const timeout = Math.max(cfg.timeout_ms ?? 20000, 20000);
|
||||
|
||||
const url = `${base}/customers/${encodeURIComponent(id)}${force ? "?force=true" : ""}`;
|
||||
const data = await fetchWoo({
|
||||
url,
|
||||
method: "DELETE",
|
||||
timeout,
|
||||
headers: { Authorization: `Basic ${auth}` },
|
||||
});
|
||||
return { ok: true, raw: data };
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,19 @@ function normalizeWooProduct(p) {
|
||||
sku: p?.sku || null,
|
||||
price: parsePrice(p?.price ?? p?.regular_price ?? p?.sale_price),
|
||||
currency: null,
|
||||
type: p?.type || null, // simple | variable | grouped | external
|
||||
attributes: Array.isArray(p?.attributes)
|
||||
? p.attributes.map((a) => ({
|
||||
name: a?.name || null,
|
||||
options: Array.isArray(a?.options) ? a.options.slice(0, 20) : [],
|
||||
}))
|
||||
: [],
|
||||
raw_price: {
|
||||
price: p?.price ?? null,
|
||||
regular_price: p?.regular_price ?? null,
|
||||
sale_price: p?.sale_price ?? null,
|
||||
price_html: p?.price_html ?? null,
|
||||
},
|
||||
payload: p,
|
||||
};
|
||||
}
|
||||
@@ -104,6 +117,7 @@ export async function searchProducts({
|
||||
maxAgeMs = 24 * 60 * 60 * 1000,
|
||||
forceWoo = false,
|
||||
}) {
|
||||
const debug = String(process.env.WOO_PRODUCTS_DEBUG || "") === "1";
|
||||
const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 10));
|
||||
const query = String(q || "").trim();
|
||||
if (!query) return { items: [], source: "none" };
|
||||
@@ -121,6 +135,22 @@ export async function searchProducts({
|
||||
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
||||
wooItems = Array.isArray(data) ? data : [];
|
||||
|
||||
if (debug) {
|
||||
console.log("[wooProducts] search", {
|
||||
tenantId,
|
||||
query,
|
||||
count: wooItems.length,
|
||||
sample: wooItems.slice(0, 5).map((p) => ({
|
||||
id: p?.id,
|
||||
name: p?.name,
|
||||
sku: p?.sku,
|
||||
price: p?.price,
|
||||
regular_price: p?.regular_price,
|
||||
sale_price: p?.sale_price,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
for (const p of wooItems) {
|
||||
const n = normalizeWooProduct(p);
|
||||
if (!n.woo_product_id || !n.name) continue;
|
||||
@@ -145,6 +175,17 @@ export async function searchProducts({
|
||||
try {
|
||||
const url = `${client.base}/products/${encodeURIComponent(c.woo_product_id)}`;
|
||||
const p = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
||||
if (debug) {
|
||||
console.log("[wooProducts] refresh", {
|
||||
tenantId,
|
||||
woo_product_id: c.woo_product_id,
|
||||
name: p?.name,
|
||||
sku: p?.sku,
|
||||
price: p?.price,
|
||||
regular_price: p?.regular_price,
|
||||
sale_price: p?.sale_price,
|
||||
});
|
||||
}
|
||||
const n = normalizeWooProduct(p);
|
||||
if (!n.woo_product_id || !n.name) continue;
|
||||
// Si cambió el precio (o faltaba), actualizamos.
|
||||
@@ -185,6 +226,9 @@ export async function searchProducts({
|
||||
sku: p.sku,
|
||||
price: p.price,
|
||||
currency: p.currency,
|
||||
type: p.type,
|
||||
attributes: p.attributes,
|
||||
raw_price: p.raw_price,
|
||||
source: "woo",
|
||||
}))
|
||||
: toReturn.map((c) => ({
|
||||
@@ -194,6 +238,19 @@ export async function searchProducts({
|
||||
price: c.price == null ? null : Number(c.price),
|
||||
currency: c.currency,
|
||||
refreshed_at: c.refreshed_at,
|
||||
type: c?.payload?.type || null,
|
||||
attributes: Array.isArray(c?.payload?.attributes)
|
||||
? c.payload.attributes.map((a) => ({
|
||||
name: a?.name || null,
|
||||
options: Array.isArray(a?.options) ? a.options.slice(0, 20) : [],
|
||||
}))
|
||||
: [],
|
||||
raw_price: {
|
||||
price: c?.payload?.price ?? null,
|
||||
regular_price: c?.payload?.regular_price ?? null,
|
||||
sale_price: c?.payload?.sale_price ?? null,
|
||||
price_html: c?.payload?.price_html ?? null,
|
||||
},
|
||||
source: isStale(c.refreshed_at, maxAgeMs) ? "cache_stale" : "cache",
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user