From 303c3daafee5a97306fb1b44b8a76294445fab78 Mon Sep 17 00:00:00 2001
From: Lucas Tettamanti <757326+lkzwieder@users.noreply.github.com>
Date: Fri, 2 Jan 2026 16:49:35 -0300
Subject: [PATCH] woocommerce integration, controllers and handlers ready,
evolution api simulator ready
---
TODO.md | 9 ++
.../20260102183608_tenant_config_change.sql | 13 ++
index.js | 87 +++---------
public/components/chat-simulator.js | 127 ++++++++++++------
public/lib/api.js | 6 +-
src/controllers/conversationState.js | 16 +++
src/controllers/conversations.js | 13 ++
src/controllers/evolution.js | 12 ++
src/controllers/runs.js | 29 ++++
src/controllers/sim.js | 12 ++
src/db/repo.js | 93 ++++++++++---
src/handlers/conversationState.js | 21 +++
src/handlers/conversations.js | 13 ++
src/handlers/evolution.js | 29 ++++
src/handlers/runs.js | 14 ++
src/handlers/sim.js | 30 +++++
src/services/evolutionParser.js | 56 ++++++++
src/services/pipeline.js | 84 +++++++++++-
src/services/woo.js | 114 ++++++++++++++++
19 files changed, 637 insertions(+), 141 deletions(-)
create mode 100644 TODO.md
create mode 100644 db/migrations/20260102183608_tenant_config_change.sql
create mode 100644 src/controllers/conversationState.js
create mode 100644 src/controllers/conversations.js
create mode 100644 src/controllers/evolution.js
create mode 100644 src/controllers/runs.js
create mode 100644 src/controllers/sim.js
create mode 100644 src/handlers/conversationState.js
create mode 100644 src/handlers/conversations.js
create mode 100644 src/handlers/evolution.js
create mode 100644 src/handlers/runs.js
create mode 100644 src/handlers/sim.js
create mode 100644 src/services/evolutionParser.js
create mode 100644 src/services/woo.js
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..a127cd5
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,9 @@
+# TODOs
+
+- Integrar WooCommerce real en `src/services/woo.js` (reemplazar stub `createWooCustomer` con llamadas a la API y manejo de errores; usar creds/config desde env).
+- Pipeline: cuando Woo devuelva el cliente real, mantener/actualizar el mapping en `wa_identity_map` vía `upsertWooCustomerMap`.
+- Conectar con OpenAI en `src/services/pipeline.js` usando `llmInput` y validar el output con esquema (Zod) antes de guardar el run.
+- (Opcional) Endpoint interno para forzar/upsert de mapping Woo ↔ wa_chat_id, reutilizando repo/woo service.
+- Revisar manejo de multi-tenant en simulador/UI (instance/tenant_key) y asegurar consistencia en `resolveTenantId`/webhooks.
+- Enterprise: mover credenciales de Woo (u otras tiendas) a secret manager (Vault/AWS SM/etc.), solo referenciarlas desde DB por clave/ID; auditar acceso a secretos y mapping; soportar rotación de keys.
+- Enterprise: tabla de config por tenant para e-commerce genérico (tipo, base_url, cred_ref/secret_ref, timeout), con soporte a Woo/otros; sin almacenar secretos en claro.
diff --git a/db/migrations/20260102183608_tenant_config_change.sql b/db/migrations/20260102183608_tenant_config_change.sql
new file mode 100644
index 0000000..3fd407f
--- /dev/null
+++ b/db/migrations/20260102183608_tenant_config_change.sql
@@ -0,0 +1,13 @@
+-- migrate:up
+ALTER TABLE tenant_ecommerce_config DROP COLUMN credential_secret;
+ALTER TABLE tenant_ecommerce_config RENAME COLUMN credential_key TO credential_ref;
+ALTER TABLE tenant_ecommerce_config ADD COLUMN enc_consumer_key bytea;
+ALTER TABLE tenant_ecommerce_config ADD COLUMN enc_consumer_secret bytea;
+ALTER TABLE tenant_ecommerce_config ADD COLUMN encryption_salt bytea DEFAULT gen_random_bytes(16);
+
+-- migrate:down
+ALTER TABLE tenant_ecommerce_config DROP COLUMN encryption_salt;
+ALTER TABLE tenant_ecommerce_config DROP COLUMN enc_consumer_secret;
+ALTER TABLE tenant_ecommerce_config DROP COLUMN enc_consumer_key;
+ALTER TABLE tenant_ecommerce_config RENAME COLUMN credential_ref TO credential_key;
+ALTER TABLE tenant_ecommerce_config ADD COLUMN credential_secret text NOT NULL;
\ No newline at end of file
diff --git a/index.js b/index.js
index f70503f..029fc43 100644
--- a/index.js
+++ b/index.js
@@ -1,12 +1,15 @@
import "dotenv/config";
import express from "express";
import cors from "cors";
-import crypto from "crypto";
import path from "path";
import { fileURLToPath } from "url";
-import { ensureTenant, listConversations, listRuns, getRunById } from "./src/db/repo.js";
-import { addSseClient, removeSseClient, sseSend } from "./src/services/sse.js";
-import { processMessage } from "./src/services/pipeline.js";
+import { ensureTenant } from "./src/db/repo.js";
+import { addSseClient, removeSseClient } from "./src/services/sse.js";
+import { makeGetConversations } from "./src/controllers/conversations.js";
+import { makeListRuns, makeGetRunById } from "./src/controllers/runs.js";
+import { makeSimSend } from "./src/controllers/sim.js";
+import { makeEvolutionWebhook } from "./src/controllers/evolution.js";
+import { makeGetConversationState } from "./src/controllers/conversationState.js";
const app = express();
app.use(cors());
@@ -46,79 +49,19 @@ app.get("/stream", (req, res) => {
});
});
-/**
- * --- Simulator ---
- * POST /sim/send { chat_id, from_phone, text }
- */
-app.post("/sim/send", async (req, res) => {
- const { chat_id, from_phone, text } = req.body || {};
- if (!chat_id || !from_phone || !text) {
- return res.status(400).json({ ok: false, error: "chat_id, from_phone, text are required" });
- }
-
- try {
- const provider = "sim";
- const message_id = crypto.randomUUID(); // idempotencia por mensaje sim
- const result = await processMessage({
- tenantId: TENANT_ID,
- chat_id,
- from: from_phone,
- text,
- provider,
- message_id,
- });
- res.json({ ok: true, run_id: result.run_id, reply: result.reply });
- } catch (err) {
- console.error(err);
- res.status(500).json({ ok: false, error: "internal_error", detail: String(err?.message || err) });
- }
-});
-
/**
* --- UI data endpoints ---
*/
-app.get("/conversations", async (req, res) => {
- const { q = "", status = "", state = "", limit = "50" } = req.query;
- try {
- const items = await listConversations({
- tenant_id: TENANT_ID,
- q: String(q || ""),
- status: String(status || ""),
- state: String(state || ""),
- limit: parseInt(limit, 10) || 50,
- });
- res.json({ items });
- } catch (err) {
- console.error(err);
- res.status(500).json({ ok: false, error: "internal_error" });
- }
-});
+app.post("/sim/send", makeSimSend());
-app.get("/runs", async (req, res) => {
- const { chat_id = null, limit = "50" } = req.query;
- try {
- const items = await listRuns({
- tenant_id: TENANT_ID,
- wa_chat_id: chat_id ? String(chat_id) : null,
- limit: parseInt(limit, 10) || 50,
- });
- res.json({ items });
- } catch (err) {
- console.error(err);
- res.status(500).json({ ok: false, error: "internal_error" });
- }
-});
+app.get("/conversations", makeGetConversations(() => TENANT_ID));
+app.get("/conversations/state", makeGetConversationState(() => TENANT_ID));
-app.get("/runs/:run_id", async (req, res) => {
- try {
- const run = await getRunById({ tenant_id: TENANT_ID, run_id: req.params.run_id });
- if (!run) return res.status(404).json({ ok: false, error: "not_found" });
- res.json(run);
- } catch (err) {
- console.error(err);
- res.status(500).json({ ok: false, error: "internal_error" });
- }
-});
+app.get("/runs", makeListRuns(() => TENANT_ID));
+
+app.get("/runs/:run_id", makeGetRunById(() => TENANT_ID));
+
+app.post("/webhook/evolution", makeEvolutionWebhook());
app.get("/", (req, res) => {
res.sendFile(path.join(publicDir, "index.html"));
diff --git a/public/components/chat-simulator.js b/public/components/chat-simulator.js
index bfa97dc..7881c7b 100644
--- a/public/components/chat-simulator.js
+++ b/public/components/chat-simulator.js
@@ -23,22 +23,24 @@ class ChatSimulator extends HTMLElement {
-
-
-
Chat
+
+
+
+
+
+
+
Chat
-
+
-
+
@@ -50,47 +52,88 @@ class ChatSimulator extends HTMLElement {
}
connectedCallback() {
- const fromEl = this.shadowRoot.getElementById("from");
- const chatEl = this.shadowRoot.getElementById("chat");
- const resetEl = this.shadowRoot.getElementById("reset");
- const sendEl = this.shadowRoot.getElementById("send");
+ const evoInstanceEl = this.shadowRoot.getElementById("instance");
+ const evoFromEl = this.shadowRoot.getElementById("evoFrom");
+ const evoToEl = this.shadowRoot.getElementById("evoTo");
+ const evoPushEl = this.shadowRoot.getElementById("pushName");
+ const evoTextEl = this.shadowRoot.getElementById("evoText");
+ const sendEvoEl = this.shadowRoot.getElementById("sendEvo");
- resetEl.onclick = () => {
- this.shadowRoot.getElementById("log").innerHTML = "";
- this.shadowRoot.getElementById("raw").textContent = "—";
- const phone = (fromEl.value || "+5491100000000").trim();
- chatEl.value = `sim:${phone}`;
- this.append("bot", "Chat reseteado (solo UI). Enviá un mensaje para generar runs.");
- };
+ const sendAction = async () => {
+ 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
+ const text = evoTextEl.value.trim();
+ const pushName = evoPushEl.value.trim();
- sendEl.onclick = async () => {
- const text = this.shadowRoot.getElementById("text").value.trim();
- if (!text) return;
-
- const from_phone = fromEl.value.trim();
- const chat_id = chatEl.value.trim();
- if (!from_phone || !chat_id) return alert("Falta teléfono o chat_id");
-
- this.append("user", text);
- this.shadowRoot.getElementById("text").value = "";
-
- const data = await api.simSend({ chat_id, from_phone, text });
- this.shadowRoot.getElementById("raw").textContent = JSON.stringify(data, null, 2);
-
- if (!data.ok) {
- this.append("bot", "Error en simulación.");
+ if (!from || !text) {
+ alert("Falta from o text");
return;
}
- this.append("bot", data.reply);
- emit("ui:selectedChat", { chat_id });
+ 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;
+ }
+
+ emit("ui:selectedChat", { chat_id: from });
+ this.append("user", text);
+ this.append("bot", `[Evolution] enviado (sim): ${text}`);
+ evoTextEl.value = "";
};
- // si querés, cuando llega un upsert de conversación simulada, podés auto-seleccionarla
+ sendEvoEl.onclick = sendAction;
+
+ evoTextEl.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ sendAction();
+ }
+ });
+
this._unsub = on("conversation:upsert", (c) => {
- const chat_id = this.shadowRoot.getElementById("chat").value.trim();
+ const chat_id = evoFromEl.value.trim() || "5491133230322@s.whatsapp.net";
if (c.chat_id === chat_id) {
- // no-op, pero podrías reflejar estado/intent acá si querés
+ // placeholder: podrías reflejar estado/intent acá si querés
}
});
}
diff --git a/public/lib/api.js b/public/lib/api.js
index cefb417..db9dff5 100644
--- a/public/lib/api.js
+++ b/public/lib/api.js
@@ -14,11 +14,11 @@ export const api = {
return fetch(u).then(r => r.json());
},
- async simSend({ chat_id, from_phone, text }) {
- return fetch("/sim/send", {
+ async simEvolution(payload) {
+ return fetch("/webhook/evolution", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ chat_id, from_phone, text }),
+ body: JSON.stringify(payload),
}).then(r => r.json());
},
};
diff --git a/src/controllers/conversationState.js b/src/controllers/conversationState.js
new file mode 100644
index 0000000..81c3379
--- /dev/null
+++ b/src/controllers/conversationState.js
@@ -0,0 +1,16 @@
+import { handleGetConversationState } from "../handlers/conversationState.js";
+
+export const makeGetConversationState = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const { status, payload } = await handleGetConversationState({
+ tenantId,
+ chat_id: req.query.chat_id || null,
+ });
+ res.status(status).json(payload);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ ok: false, error: "internal_error" });
+ }
+};
+
diff --git a/src/controllers/conversations.js b/src/controllers/conversations.js
new file mode 100644
index 0000000..dacb326
--- /dev/null
+++ b/src/controllers/conversations.js
@@ -0,0 +1,13 @@
+import { handleListConversations } from "../handlers/conversations.js";
+
+export const makeGetConversations = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const items = await handleListConversations({ tenantId, query: req.query });
+ res.json({ items });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ ok: false, error: "internal_error" });
+ }
+};
+
diff --git a/src/controllers/evolution.js b/src/controllers/evolution.js
new file mode 100644
index 0000000..46b65d2
--- /dev/null
+++ b/src/controllers/evolution.js
@@ -0,0 +1,12 @@
+import { handleEvolutionWebhook } from "../handlers/evolution.js";
+
+export const makeEvolutionWebhook = () => async (req, res) => {
+ try {
+ const result = await handleEvolutionWebhook(req.body || {});
+ res.status(result.status).json(result.payload);
+ } catch (err) {
+ console.error(err);
+ res.status(200).json({ ok: true, error: "internal_error" });
+ }
+};
+
diff --git a/src/controllers/runs.js b/src/controllers/runs.js
new file mode 100644
index 0000000..33144e9
--- /dev/null
+++ b/src/controllers/runs.js
@@ -0,0 +1,29 @@
+import { handleListRuns, handleGetRun } from "../handlers/runs.js";
+
+export const makeListRuns = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const items = await handleListRuns({
+ tenantId,
+ chat_id: req.query.chat_id || null,
+ limit: req.query.limit || "50",
+ });
+ res.json({ items });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ ok: false, error: "internal_error" });
+ }
+};
+
+export const makeGetRunById = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const run = await handleGetRun({ tenantId, run_id: req.params.run_id });
+ if (!run) return res.status(404).json({ ok: false, error: "not_found" });
+ res.json(run);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ ok: false, error: "internal_error" });
+ }
+};
+
diff --git a/src/controllers/sim.js b/src/controllers/sim.js
new file mode 100644
index 0000000..5fef905
--- /dev/null
+++ b/src/controllers/sim.js
@@ -0,0 +1,12 @@
+import { handleSimSend } from "../handlers/sim.js";
+
+export const makeSimSend = () => async (req, res) => {
+ try {
+ const result = await handleSimSend(req.body || {});
+ res.status(result.status).json(result.payload);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ ok: false, error: "internal_error", detail: String(err?.message || err) });
+ }
+};
+
diff --git a/src/db/repo.js b/src/db/repo.js
index efc32a7..eca4848 100644
--- a/src/db/repo.js
+++ b/src/db/repo.js
@@ -242,25 +242,6 @@ export async function getRunById({ tenant_id, run_id }) {
};
}
-async function getRecentMessages({ tenant_id, wa_chat_id, limit = 20 }) {
- const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 20)); // hard cap 50
- const q = `
- select direction, ts, text
- from wa_messages
- where tenant_id=$1 and wa_chat_id=$2
- order by ts desc
- limit $3
- `;
- const { rows } = await pool.query(q, [tenant_id, wa_chat_id, lim]);
-
- // rows vienen newest-first, lo devolvemos oldest-first
- return rows.reverse().map(r => ({
- role: r.direction === "in" ? "user" : "assistant",
- content: r.text || "",
- ts: r.ts,
- }));
-}
-
export async function getRecentMessagesForLLM({
tenant_id,
wa_chat_id,
@@ -285,3 +266,77 @@ export async function getRecentMessagesForLLM({
content: String(r.text).trim().slice(0, maxCharsPerMessage),
}));
}
+
+export async function getTenantByKey(key) {
+ const { rows } = await pool.query(`select id, key, name from tenants where key=$1`, [key]);
+ return rows[0] || null;
+}
+
+export async function getTenantIdByChannel({ channel_type, channel_key }) {
+ const q = `
+ select tenant_id
+ from tenant_channels
+ where channel_type=$1 and channel_key=$2
+ `;
+ const { rows } = await pool.query(q, [channel_type, channel_key]);
+ return rows[0]?.tenant_id || null;
+}
+
+export async function getExternalCustomerIdByChat({ tenant_id, wa_chat_id, provider = "woo" }) {
+ const q = `
+ select external_customer_id
+ from wa_identity_map
+ where tenant_id=$1 and wa_chat_id=$2 and provider=$3
+ `;
+ const { rows } = await pool.query(q, [tenant_id, wa_chat_id, provider]);
+ return rows[0]?.external_customer_id || null;
+}
+
+export async function upsertExternalCustomerMap({
+ tenant_id,
+ wa_chat_id,
+ external_customer_id,
+ provider = "woo",
+}) {
+ const q = `
+ insert into wa_identity_map (tenant_id, wa_chat_id, provider, external_customer_id, created_at, updated_at)
+ values ($1, $2, $3, $4, now(), now())
+ on conflict (tenant_id, wa_chat_id, provider)
+ do update set external_customer_id = excluded.external_customer_id, updated_at = now()
+ returning external_customer_id
+ `;
+ const { rows } = await pool.query(q, [tenant_id, wa_chat_id, provider, external_customer_id]);
+ return rows[0]?.external_customer_id || null;
+}
+
+export async function getTenantEcommerceConfig({ tenant_id, provider = "woo" }) {
+ const q = `
+ select id, tenant_id, provider, base_url, credential_ref, api_version, timeout_ms,
+ enc_consumer_key, enc_consumer_secret, encryption_salt, enabled
+ from tenant_ecommerce_config
+ where tenant_id = $1 and provider = $2 and enabled = true
+ limit 1
+ `;
+ const { rows } = await pool.query(q, [tenant_id, provider]);
+ return rows[0] || null;
+}
+
+export async function getDecryptedTenantEcommerceConfig({
+ tenant_id,
+ provider = "woo",
+ encryption_key,
+}) {
+ if (!encryption_key) {
+ throw new Error("encryption_key is required to decrypt ecommerce credentials");
+ }
+ const q = `
+ select id, tenant_id, provider, base_url, credential_ref, api_version, timeout_ms, enabled,
+ pgp_sym_decrypt(enc_consumer_key, $3)::text as consumer_key,
+ pgp_sym_decrypt(enc_consumer_secret, $3)::text as consumer_secret
+ from tenant_ecommerce_config
+ where tenant_id = $1 and provider = $2 and enabled = true
+ limit 1
+ `;
+ const { rows } = await pool.query(q, [tenant_id, provider, encryption_key]);
+ return rows[0] || null;
+}
\ No newline at end of file
diff --git a/src/handlers/conversationState.js b/src/handlers/conversationState.js
new file mode 100644
index 0000000..39e05eb
--- /dev/null
+++ b/src/handlers/conversationState.js
@@ -0,0 +1,21 @@
+import { getConversationState } from "../db/repo.js";
+
+export async function handleGetConversationState({ tenantId, chat_id }) {
+ if (!chat_id) {
+ return { status: 400, payload: { ok: false, error: "chat_id required" } };
+ }
+ const row = await getConversationState(tenantId, chat_id);
+ if (!row) return { status: 404, payload: { ok: false, error: "not_found" } };
+ return {
+ status: 200,
+ payload: {
+ ok: true,
+ state: row.state,
+ last_intent: row.last_intent,
+ last_order_id: row.last_order_id,
+ context: row.context,
+ state_updated_at: row.state_updated_at,
+ },
+ };
+}
+
diff --git a/src/handlers/conversations.js b/src/handlers/conversations.js
new file mode 100644
index 0000000..29c5b42
--- /dev/null
+++ b/src/handlers/conversations.js
@@ -0,0 +1,13 @@
+import { listConversations } from "../db/repo.js";
+
+export async function handleListConversations({ tenantId, query }) {
+ const { q = "", status = "", state = "", limit = "50" } = query || {};
+ return listConversations({
+ tenant_id: tenantId,
+ q: String(q || ""),
+ status: String(status || ""),
+ state: String(state || ""),
+ limit: parseInt(limit, 10) || 50,
+ });
+}
+
diff --git a/src/handlers/evolution.js b/src/handlers/evolution.js
new file mode 100644
index 0000000..734df03
--- /dev/null
+++ b/src/handlers/evolution.js
@@ -0,0 +1,29 @@
+import crypto from "crypto";
+import { parseEvolutionWebhook } from "../services/evolutionParser.js";
+import { resolveTenantId, processMessage } from "../services/pipeline.js";
+
+export async function handleEvolutionWebhook(body) {
+ const parsed = parseEvolutionWebhook(body);
+ if (!parsed.ok) {
+ return { status: 200, payload: { ok: true, ignored: parsed.reason } };
+ }
+
+ const tenantId = await resolveTenantId({
+ chat_id: parsed.chat_id,
+ tenant_key: parsed.tenant_key,
+ to_phone: null,
+ });
+
+ await processMessage({
+ tenantId,
+ chat_id: parsed.chat_id,
+ from: parsed.chat_id.replace("@s.whatsapp.net", ""),
+ text: parsed.text,
+ provider: "evolution",
+ message_id: parsed.message_id || crypto.randomUUID(),
+ meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key },
+ });
+
+ return { status: 200, payload: { ok: true } };
+}
+
diff --git a/src/handlers/runs.js b/src/handlers/runs.js
new file mode 100644
index 0000000..e29e675
--- /dev/null
+++ b/src/handlers/runs.js
@@ -0,0 +1,14 @@
+import { listRuns, getRunById } from "../db/repo.js";
+
+export async function handleListRuns({ tenantId, chat_id = null, limit = "50" }) {
+ return listRuns({
+ tenant_id: tenantId,
+ wa_chat_id: chat_id ? String(chat_id) : null,
+ limit: parseInt(limit, 10) || 50,
+ });
+}
+
+export async function handleGetRun({ tenantId, run_id }) {
+ return getRunById({ tenant_id: tenantId, run_id });
+}
+
diff --git a/src/handlers/sim.js b/src/handlers/sim.js
new file mode 100644
index 0000000..2180edb
--- /dev/null
+++ b/src/handlers/sim.js
@@ -0,0 +1,30 @@
+import crypto from "crypto";
+import { resolveTenantId } from "../services/pipeline.js";
+import { processMessage } from "../services/pipeline.js";
+
+export async function handleSimSend(body) {
+ const { chat_id, from_phone, text } = body || {};
+ if (!chat_id || !from_phone || !text) {
+ return { status: 400, payload: { ok: false, error: "chat_id, from_phone, text are required" } };
+ }
+
+ const provider = "sim";
+ const message_id = crypto.randomUUID();
+ const tenantId = await resolveTenantId({
+ chat_id,
+ tenant_key: body?.tenant_key,
+ to_phone: body?.to_phone,
+ });
+
+ const result = await processMessage({
+ tenantId,
+ chat_id,
+ from: from_phone,
+ text,
+ provider,
+ message_id,
+ });
+
+ return { status: 200, payload: { ok: true, run_id: result.run_id, reply: result.reply } };
+}
+
diff --git a/src/services/evolutionParser.js b/src/services/evolutionParser.js
new file mode 100644
index 0000000..c482b67
--- /dev/null
+++ b/src/services/evolutionParser.js
@@ -0,0 +1,56 @@
+export function parseEvolutionWebhook(reqBody) {
+ // n8n a veces entrega array con { body }, en express real suele ser directo
+ const envelope = Array.isArray(reqBody) ? reqBody[0] : reqBody;
+ const body = envelope?.body ?? envelope ?? {};
+
+ const event = body.event;
+ const instance = body.instance; // tenant key
+ const data = body.data;
+
+ if (!event || !data || !data.key) {
+ return { ok: false, reason: "missing_fields" };
+ }
+
+ if (event !== "messages.upsert") {
+ return { ok: false, reason: "not_messages_upsert" };
+ }
+
+ const remoteJid = data.key.remoteJid;
+ const fromMe = data.key.fromMe === true;
+ const messageId = data.key.id;
+
+ // only inbound
+ if (fromMe) return { ok: false, reason: "from_me" };
+
+ // ignore groups / broadcasts
+ if (!remoteJid || typeof remoteJid !== "string") return { ok: false, reason: "no_remoteJid" };
+ if (!remoteJid.endsWith("@s.whatsapp.net")) return { ok: false, reason: "not_direct_chat" };
+
+ const messageType = data.messageType;
+
+ // extract text
+ const msg = data.message || {};
+ const text =
+ (typeof msg.conversation === "string" && msg.conversation) ||
+ (typeof msg.extendedTextMessage?.text === "string" && msg.extendedTextMessage.text) ||
+ "";
+
+ const cleanText = String(text).trim();
+ if (!cleanText) return { ok: false, reason: "empty_text" };
+
+ // metadata
+ const pushName = data.pushName || null;
+ const ts = data.messageTimestamp ? new Date(Number(data.messageTimestamp) * 1000).toISOString() : null;
+
+ return {
+ ok: true,
+ tenant_key: instance || null,
+ chat_id: remoteJid,
+ message_id: messageId || null,
+ text: cleanText,
+ from_name: pushName,
+ message_type: messageType || null,
+ ts,
+ raw: body, // para log/debug si querés
+ };
+}
diff --git a/src/services/pipeline.js b/src/services/pipeline.js
index c39a9cf..cb69dae 100644
--- a/src/services/pipeline.js
+++ b/src/services/pipeline.js
@@ -5,8 +5,11 @@ import {
insertRun,
upsertConversationState,
getRecentMessagesForLLM,
+ getExternalCustomerIdByChat,
+ upsertExternalCustomerMap,
} from "../db/repo.js";
import { sseSend } from "./sse.js";
+import { createWooCustomer, getWooCustomerById } from "./woo.js";
function nowIso() {
@@ -20,7 +23,15 @@ function newId(prefix = "run") {
export async function processMessage({ tenantId, chat_id, from, text, provider, message_id }) {
const started_at = Date.now();
const prev = await getConversationState(tenantId, chat_id);
- const prev_state = prev?.state || "IDLE";
+ const isStale =
+ prev?.state_updated_at &&
+ Date.now() - new Date(prev.state_updated_at).getTime() > 24 * 60 * 60 * 1000;
+ const prev_state = isStale ? "IDLE" : prev?.state || "IDLE";
+ let externalCustomerId = await getExternalCustomerIdByChat({
+ tenant_id: tenantId,
+ wa_chat_id: chat_id,
+ provider: "woo",
+ });
await insertMessage({
tenant_id: tenantId,
@@ -79,9 +90,37 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
run_id,
});
+ // Si no tenemos cliente Woo mapeado, creamos uno (stub) y guardamos el mapping.
+ if (externalCustomerId) {
+ // validar existencia en Woo; si no existe, lo recreamos
+ const found = await getWooCustomerById({ tenantId, id: externalCustomerId });
+ if (!found) {
+ const phone = chat_id.replace(/@.+$/, "");
+ const name = from || phone;
+ const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name });
+ externalCustomerId = await upsertExternalCustomerMap({
+ tenant_id: tenantId,
+ wa_chat_id: chat_id,
+ external_customer_id: created?.id,
+ provider: "woo",
+ });
+ }
+ } else {
+ const phone = chat_id.replace(/@.+$/, "");
+ const name = from || phone;
+ const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name });
+ externalCustomerId = await upsertExternalCustomerMap({
+ tenant_id: tenantId,
+ wa_chat_id: chat_id,
+ external_customer_id: created?.id,
+ provider: "woo",
+ });
+ }
+
const context = {
missing_fields: plan.missing_fields || [],
basket_resolved: plan.basket_resolved || { items: [] },
+ external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null,
};
const stateRow = await upsertConversationState({
@@ -121,17 +160,21 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
});
const history = await getRecentMessagesForLLM({
- tenant_id: TENANT_ID,
+ tenant_id: tenantId,
wa_chat_id: chat_id,
limit: 20,
});
-
+ const compactHistory = collapseAssistantMessages(history);
+
const llmInput = {
wa_chat_id: chat_id,
last_user_message: text,
- conversation_history: history,
+ conversation_history: compactHistory,
current_conversation_state: prev_state,
- context: prev?.context || {},
+ context: {
+ ...(prev?.context || {}),
+ external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null,
+ },
};
return { run_id, reply: plan.reply };
@@ -147,3 +190,34 @@ export function collapseAssistantMessages(messages) {
return out;
}
+import { ensureTenant, getTenantByKey, getTenantIdByChannel } from "../db/repo.js";
+
+function parseTenantFromChatId(chat_id) {
+ // soporta "piaf:sim:+54..." o "piaf:+54..." etc.
+ const m = /^([a-z0-9_-]+):/.exec(chat_id);
+ return m?.[1]?.toLowerCase() || null;
+}
+
+export async function resolveTenantId({ chat_id, to_phone = null, tenant_key = null }) {
+ // Normalizar key a lowercase para evitar duplicados por casing
+ const explicit = (tenant_key || parseTenantFromChatId(chat_id) || "").toLowerCase();
+
+ // 1) si viene explícito (simulador / webhook)
+ if (explicit) {
+ const t = await getTenantByKey(explicit);
+ if (t) return t.id;
+ throw new Error(`tenant_not_found: ${explicit}`);
+ }
+
+ // 2) si viene el número receptor / channel key (producción)
+ if (to_phone) {
+ const id = await getTenantIdByChannel({ channel_type: "whatsapp", channel_key: to_phone });
+ if (id) return id;
+ }
+
+ // 3) fallback: env TENANT_KEY
+ const fallbackKey = (process.env.TENANT_KEY || "piaf").toLowerCase();
+ const t = await getTenantByKey(fallbackKey);
+ if (t) return t.id;
+ throw new Error(`tenant_not_found: ${fallbackKey}`);
+}
\ No newline at end of file
diff --git a/src/services/woo.js b/src/services/woo.js
new file mode 100644
index 0000000..b20d2f6
--- /dev/null
+++ b/src/services/woo.js
@@ -0,0 +1,114 @@
+import crypto from "crypto";
+import { getDecryptedTenantEcommerceConfig } from "../db/repo.js";
+
+async function fetchWoo({ url, method = "GET", body = null, timeout = 8000 }) {
+ const controller = new AbortController();
+ const t = setTimeout(() => controller.abort(), timeout);
+ try {
+ const res = await fetch(url, {
+ method,
+ headers: { "Content-Type": "application/json" },
+ body: body ? JSON.stringify(body) : null,
+ signal: controller.signal,
+ });
+ const text = await res.text();
+ let json = null;
+ try {
+ json = text ? JSON.parse(text) : null;
+ } catch {
+ // ignore parse error
+ }
+ if (!res.ok) {
+ const err = new Error(`Woo request failed: ${res.status}`);
+ err.status = res.status;
+ err.body = json || text;
+ throw err;
+ }
+ return json;
+ } finally {
+ clearTimeout(t);
+ }
+}
+
+export async function createWooCustomer({ tenantId, wa_chat_id, phone, name }) {
+ 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 url = `${base}/customers?consumer_key=${encodeURIComponent(
+ consumerKey
+ )}&consumer_secret=${encodeURIComponent(consumerSecret)}`;
+
+ const payload = {
+ email: `${phone || wa_chat_id}@no-email.local`,
+ first_name: name || phone || wa_chat_id,
+ username: phone || wa_chat_id,
+ password: crypto.randomBytes(12).toString("base64url"), // requerido por Woo
+ billing: {
+ phone: phone || wa_chat_id,
+ },
+ };
+
+ const data = await fetchWoo({ url, method: "POST", body: payload, timeout: cfg.timeout_ms });
+ return { id: data?.id, raw: data };
+}
+
+export async function getWooCustomerById({ tenantId, id }) {
+ if (!id) return null;
+ 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 url = `${base}/customers/${id}?consumer_key=${encodeURIComponent(
+ consumerKey
+ )}&consumer_secret=${encodeURIComponent(consumerSecret)}`;
+
+ try {
+ const data = await fetchWoo({ url, method: "GET", timeout: cfg.timeout_ms });
+ return data;
+ } catch (err) {
+ if (err.status === 404) return null;
+ throw err;
+ }
+}
+