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 {
-
Simulador WhatsApp (local)
+
Evolution Sim (único chat)
- +
- - +
-
- -
-
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; + } +} +