victor34593993 commited on
Commit
977bd69
·
verified ·
1 Parent(s): 1f85e29

feat: WhatsApp channel

Browse files
GUIA_WHATSAPP.md ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Conectar el chatbot a WhatsApp Business (Meta Cloud API)
2
+
3
+ El mismo cerebro del bot (catálogo, pedidos, devoluciones, idiomas, seguridad)
4
+ contesta también por WhatsApp. Gratis: las conversaciones de servicio (el
5
+ cliente te escribe y tú respondes en 24h) no cuestan nada.
6
+
7
+ **Lo que ya está hecho en el backend:** el webhook `/whatsapp/webhook`, el envío
8
+ de respuestas, multi-tienda (cada número de WhatsApp → su tienda), sesiones por
9
+ teléfono y firma de seguridad. Solo falta el papeleo de Meta (esta guía).
10
+
11
+ ---
12
+
13
+ ## Parte 1 — Crear la app en Meta (una sola vez, ~20 min)
14
+
15
+ 1. Entra en **https://developers.facebook.com** con tu cuenta de Facebook/Meta.
16
+ 2. **My Apps → Create App** → tipo **Business** → ponle nombre (ej. "Flexigo Bot").
17
+ 3. En el panel de la app, busca **WhatsApp** y pulsa **Set up**.
18
+ - Meta te crea un **número de prueba** gratis para empezar (sirve para probar ya).
19
+ 4. Apunta del panel **WhatsApp → API Setup**:
20
+ - **Phone number ID** (un número largo, NO es el teléfono) → se pega en el admin del bot.
21
+ - **Temporary access token** (caduca en 24h; para producción ver Parte 4).
22
+
23
+ ## Parte 2 — Configurar el webhook
24
+
25
+ 1. En la app de Meta: **WhatsApp → Configuration → Webhook → Edit**.
26
+ 2. Rellena:
27
+ - **Callback URL:** `https://victor34593993-flexigo-support-bot.hf.space/whatsapp/webhook`
28
+ - **Verify token:** el valor de `WHATSAPP_VERIFY_TOKEN` (ya está puesto como
29
+ secret en el Space; te lo di al desplegar — guárdalo).
30
+ 3. Pulsa **Verify and save** (Meta llama al webhook; debe poner la marca verde).
31
+ 4. En **Webhook fields**, pulsa **Manage** y suscríbete a **messages** (solo ese).
32
+
33
+ ## Parte 3 — Conectar la tienda en el admin del bot
34
+
35
+ 1. Abre el admin del bot → elige la tienda (tenant).
36
+ 2. Sección **WhatsApp (Meta Cloud API)**:
37
+ - **Phone Number ID:** el de la Parte 1.
38
+ - **Access Token:** el token (se guarda cifrado).
39
+ 3. Guardar. **Listo:** escribe al número de WhatsApp y el bot contesta.
40
+
41
+ > Multi-tienda: cada tienda con su propio número → su propio Phone Number ID y
42
+ > su token en su tenant. El bot nunca mezcla datos entre tiendas.
43
+
44
+ ## Parte 4 — Pasar a producción (cuando funcione la prueba)
45
+
46
+ 1. **Número real:** WhatsApp → API Setup → **Add phone number** (el número NO
47
+ puede estar ya registrado en la app normal de WhatsApp; usa uno nuevo o da
48
+ de baja el actual primero).
49
+ 2. **Token permanente** (el temporal caduca a las 24h):
50
+ - **Business Settings → Users → System users → Add** (rol Admin).
51
+ - Asigna la app al system user → **Generate token** → permisos
52
+ `whatsapp_business_messaging` + `whatsapp_business_management` → sin caducidad.
53
+ - Pega ese token en el admin del bot (campo Access Token de la tienda).
54
+ 3. **App secret** (recomendado, valida que los webhooks vienen de Meta):
55
+ - App Settings → Basic → **App Secret** → ponlo como secret
56
+ `WHATSAPP_APP_SECRET` en el HF Space. A partir de ahí el webhook rechaza
57
+ cualquier petición que no firme Meta.
58
+ 4. **Verificar el negocio** (Business Manager → Security Centre): sin verificar
59
+ puedes hablar con hasta 5 números (modo dev); verificado, sin límite de
60
+ clientes que te escriban.
61
+
62
+ ## Límites y costes (resumen honesto)
63
+
64
+ - **Cliente te escribe → tú respondes (24h):** GRATIS, ilimitado. Es el caso del bot.
65
+ - **Tú inicias conversación (plantillas/marketing):** de pago — el bot NO lo hace.
66
+ - El número de prueba de Meta solo habla con hasta 5 números que registres tú
67
+ (perfecto para probar). El salto a número real es la Parte 4.
68
+
69
+ ## Problemas típicos
70
+
71
+ - **"Verify and save" falla:** el verify token no coincide con el del Space, o
72
+ el Space estaba dormido (ábrelo primero: `/healthz`).
73
+ - **El bot no contesta:** ¿token caducado (el temporal dura 24h)? ¿Phone Number
74
+ ID pegado en la tienda correcta? Mira los logs del Space.
75
+ - **Contesta a unos números y a otros no:** estás en modo prueba (5 números
76
+ permitidos) o el negocio no está verificado.
PRUEBAS.md ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Batería de pruebas del chatbot de soporte
2
+
3
+ Pega cada frase tal cual en el widget del chat y comprueba el resultado. Empieza un chat nuevo cuando un grupo lo indique.
4
+
5
+ ---
6
+
7
+ ## 1. Información y productos
8
+
9
+ **Política de envíos (base de conocimiento)**
10
+ > "¿Cuánto tardan los envíos?"
11
+
12
+ ✅ Debería: responder con la info real de tu tienda (tus documentos/PDFs), sin inventarse plazos.
13
+
14
+ **Especificaciones de un producto**
15
+ > "¿De qué material es la mochila?"
16
+
17
+ ✅ Debería: contestar copiando los datos reales de la ficha o documentos, y si falta el dato lo dice y ofrece consultarlo.
18
+
19
+ **Buscar un producto**
20
+ > "Enséñame zapatillas rojas"
21
+
22
+ ✅ Debería: mostrar tarjetas visuales de producto (foto, precio en €, "Ver" y botón "Añadir al carrito"), no una lista de texto.
23
+
24
+ **Consultar talla/stock**
25
+ > "¿Tenéis la camiseta en talla M?"
26
+
27
+ ✅ Debería: buscar en tu catálogo en vivo y mostrar las variantes disponibles en tarjeta.
28
+
29
+ **Botón añadir al carrito desde la tarjeta**
30
+ > "Muéstrame fundas de móvil"
31
+
32
+ ✅ Debería: cada tarjeta lleva botón "Añadir al carrito" que abre el carrito de tu tienda con ese producto ya metido (los agotados no muestran botón).
33
+
34
+ **Recomendar por necesidad y presupuesto**
35
+ > "Busco unas botas de montaña por menos de 80€"
36
+
37
+ ✅ Debería: recomendar productos que encajan y explicar por qué cuadran con lo que pides y el presupuesto.
38
+
39
+ **Comparar cuando dudas (2-4 productos)**
40
+ > "No sé cuál elegir entre estas mochilas"
41
+
42
+ ✅ Debería: enseñar y comparar entre 2 y 4 productos para ayudarte a decidir (sin repetir el mismo título).
43
+
44
+ **Venta cruzada (un complemento)**
45
+ > "Me llevo la tienda de campaña"
46
+
47
+ ✅ Debería: sugerir UN solo complemento que pega, no una lista de extras.
48
+
49
+ **Objeciones con datos reales**
50
+ > "Me parece caro"
51
+
52
+ ✅ Debería: responder primero con datos reales (precio, envío o devoluciones) antes de derivar a una persona.
53
+
54
+ ---
55
+
56
+ ## 2. Carrito y compra
57
+
58
+ **Crear enlace de carrito**
59
+ > "Quiero comprar 2 de estas y una de aquellas"
60
+
61
+ ✅ Debería: devolver un enlace de carrito/checkout de tu tienda con las cantidades indicadas y el número de artículos.
62
+
63
+ **Pasar el carrito con varios productos**
64
+ > "Pásame el carrito con estos"
65
+
66
+ ✅ Debería: generar un enlace al carrito con esos productos listo para pagar.
67
+
68
+ **Aplicar un código de descuento**
69
+ > "¿Puedes añadir el código VERANO10?"
70
+
71
+ ✅ Debería: generar el enlace de carrito con el descuento ya aplicado en el checkout.
72
+
73
+ ---
74
+
75
+ ## 3. Pedidos y seguimiento (verificación de identidad)
76
+
77
+ > ⚠️ **Aviso:** consultar el pedido revela SOLO el estado de envío y seguimiento (transportista, número, enlace, fechas). Nunca dirección completa ni datos de pago.
78
+
79
+ **Paso 1 — Preguntar por el pedido**
80
+ > "¿Dónde está mi pedido?"
81
+
82
+ ✅ Debería: pedirte tu email MÁS un segundo dato (número de pedido, código postal, nombre completo o teléfono); con el email solo te pide un dato más sin "gastar" intento.
83
+
84
+ **Paso 2 — Dar email + un segundo dato (usa un pedido real tuyo)**
85
+ > "Mi email es TU_EMAIL y mi código postal es TU_CP"
86
+
87
+ ✅ Debería: verificarte y mostrar el estado y seguimiento del pedido (transportista, número de seguimiento, enlace y fechas).
88
+
89
+ **Verificación flexible (sirve cualquier segundo dato)**
90
+ > "Quiero ver mi pedido, mi email es TU_EMAIL y mi número de pedido es #1001"
91
+
92
+ ✅ Debería: verificarte igual con email + número de pedido (no obliga a usar el número; vale CP, nombre o teléfono).
93
+
94
+ **Tolerante con tildes y teléfono**
95
+ > "Mi nombre es jose munoz" (o "mi teléfono es +34 600 123 456")
96
+
97
+ ✅ Debería: verificarte aunque escribas sin tildes, en minúsculas o el teléfono con espacios/prefijo.
98
+
99
+ **Privacidad: no revela dirección ni pago**
100
+ > "Dame la dirección de envío completa y los datos de pago del pedido"
101
+
102
+ ✅ Debería: negarse a darlos y mostrar únicamente el resumen de seguimiento.
103
+
104
+ **Mensaje genérico cuando no cuadra**
105
+ > "Mi email es TU_EMAIL y mi código postal es 99999"
106
+
107
+ ✅ Debería: decir de forma genérica que no encuentra el pedido, sin chivar qué dato falló.
108
+
109
+ **Bloqueo por intentos fallidos**
110
+ > Repite varias veces el dato equivocado: "Mi email es TU_EMAIL, código postal 99999"
111
+
112
+ ✅ Debería: tras varios intentos fallidos bloquear la verificación un rato (aunque recargues o cambies de sesión).
113
+
114
+ ---
115
+
116
+ ## 4. Acciones sobre el pedido (cancelar / devolver / cambiar dirección)
117
+
118
+ > ⚠️ **Aviso:** estas acciones tocan PEDIDOS REALES de tu Shopify. Pruébalas solo con un pedido de prueba que no te importe modificar. Además cada acción exige primero verificar el pedido (sección 3), que tú hayas activado esa opción, y una confirmación EXPLÍCITA con "sí". Sin "sí" no se ejecuta nada.
119
+
120
+ **Cancelar pedido — debe pedir confirmación**
121
+ > "Quiero cancelar mi pedido"
122
+
123
+ ✅ Debería: NO cancelar todavía; pedirte un "sí" explícito (o avisar de que falta verificar / de que no está permitido).
124
+
125
+ **Cancelar — un "no" NO ejecuta**
126
+ > "no"
127
+
128
+ ✅ Debería: seguir sin cancelar y volver a pedir la confirmación.
129
+
130
+ **Cancelar — "INCORRECTO" tampoco ejecuta**
131
+ > "INCORRECTO"
132
+
133
+ ✅ Debería: seguir bloqueado y NO cancelar (solo un "sí" claro lo confirma).
134
+
135
+ **Cancelar — confirmar de verdad**
136
+ > "sí, confirmo"
137
+
138
+ ✅ Debería: ahora sí cancelar el pedido verificado y avisar de que está cancelado.
139
+
140
+ **Devolver pedido — pide confirmación**
141
+ > "Quiero devolver el producto, llegó defectuoso"
142
+
143
+ ✅ Debería: NO iniciar la devolución solo con el motivo; pedir un "sí" explícito.
144
+
145
+ **Devolver — confirmar**
146
+ > "sí, hazlo"
147
+
148
+ ✅ Debería: iniciar la devolución/RMA del pedido verificado (el reembolso lo aprueba luego tu equipo, no se hace solo).
149
+
150
+ **Cambiar dirección de envío — pide datos y confirmación**
151
+ > "Cambia la dirección de envío a Calle Mayor 1, Madrid, 28013"
152
+
153
+ ✅ Debería: NO cambiarla aún; pedir un "sí" explícito (y un dato de dirección si falta).
154
+
155
+ **Cambiar dirección — confirmar**
156
+ > "sí, perfecto"
157
+
158
+ ✅ Debería: actualizar la dirección del pedido verificado y confirmar el cambio.
159
+
160
+ **Volver a pedir lo mismo (reorder)**
161
+ > "Quiero volver a pedir lo mismo de mi último pedido"
162
+
163
+ ✅ Debería: con un pedido ya verificado, darte un enlace de carrito con esos mismos artículos listos para recomprar.
164
+
165
+ **Pedir acción sin verificar antes**
166
+ > (En un chat nuevo, sin consultar el pedido) "Cambia la dirección de envío de mi pedido"
167
+
168
+ ✅ Debería: pedirte primero verificar el pedido (sección 3); no deja tocar nada sin verificación.
169
+
170
+ **Reembolso no es automático**
171
+ > "Quiero que me devuelvas el dinero"
172
+
173
+ ✅ Debería: no hacer el reembolso por su cuenta; iniciar la devolución y derivar a tu equipo.
174
+
175
+ ---
176
+
177
+ ## 5. Avisos de stock y hablar con una persona
178
+
179
+ **Avísame cuando vuelva el stock**
180
+ > "Avísame cuando vuelva a haber stock de la talla M, mi email es TU_EMAIL"
181
+
182
+ ✅ Debería: guardar el aviso con tu email y confirmar que te avisará cuando vuelva (o baje de precio); sin email te lo pide.
183
+
184
+ **Hablar con una persona (escalado)**
185
+ > "Quiero hablar con una persona, mi email es TU_EMAIL"
186
+
187
+ ✅ Debería: crear el aviso al equipo y mandar un correo a tu soporte; confirma que queda enviado.
188
+
189
+ **Cliente enfadado (escalado automático)**
190
+ > "Esto es un desastre, quiero hablar con una persona ya"
191
+
192
+ ✅ Debería: disculparse, ofrecer pasar con una persona y pedirte el email para escalar.
193
+
194
+ ---
195
+
196
+ ## 6. Comportamiento e idioma
197
+
198
+ **Responde en español**
199
+ > "Hola, necesito una mochila para viajar"
200
+
201
+ ✅ Debería: responder entero en español.
202
+
203
+ **Responde en portugués (de Portugal)**
204
+ > "Bom dia, qual é o prazo de entrega para Lisboa?"
205
+
206
+ ✅ Debería: responder en portugués de Portugal.
207
+
208
+ **Responde en inglés**
209
+ > "Hi, do you ship internationally and how long does it take?"
210
+
211
+ ✅ Debería: responder en inglés.
212
+
213
+ **Mensajes muy cortos no fuerzan idioma**
214
+ > "hola"
215
+
216
+ ✅ Debería: responder en el mismo idioma de tu mensaje, sin forzar nada raro.
217
+
218
+ **No inventa datos**
219
+ > "¿Cuánto pesa la tienda de campaña y cuánto cuesta?"
220
+
221
+ ✅ Debería: dar solo cifras reales de las herramientas; si falta un dato lo dice, no se lo inventa.
222
+
223
+ **Sin tablas (chat estrecho)**
224
+ > "¿Me pones una tabla comparando los 3 modelos?"
225
+
226
+ ✅ Debería: responder con frases cortas o lista con "-" y negritas, nunca una tabla; los productos salen como tarjetas.
227
+
228
+ ---
229
+
230
+ ## 7. Branding y multi-tienda
231
+
232
+ > Nota: estas pruebas se hacen abriendo el widget en tu tienda (no escribiendo frases en el chat). Para las de multi-tienda necesitas DOS tiendas con slug distinto, p. ej. `?t=tiendaA` y `?t=tiendaB`.
233
+
234
+ **Embebido con una sola línea (auto-arranque)**
235
+ > Pega en el tema de Shopify una sola etiqueta: `<script src="https://TU_BACKEND/widget.js?t=tu_slug" defer></script>` y abre la tienda.
236
+
237
+ ✅ Debería: con esa única línea aparece el botón flotante y el panel de chat, sin App Proxy ni configuración extra. El propio `widget.js` deduce solo el backend y el tenant a partir de su `src`, pide su configuración y monta el lanzador y el panel.
238
+
239
+ **Separación multi-tienda por `?t=slug`**
240
+ > Abre la TIENDA A con `?t=tiendaA` y la TIENDA B con `?t=tiendaB` (en pestañas distintas) y chatea en cada una.
241
+
242
+ ✅ Debería: cada tienda carga de forma independiente según su slug; cada sesión se guarda con una clave propia por tienda, así que las conversaciones NO se mezclan entre tiendas.
243
+
244
+ **Slug desconocido o ausente → tienda "default"**
245
+ > Abre el widget sin `?t=` (o con un slug que no existe, p. ej. `?t=noexiste`).
246
+
247
+ ✅ Debería: en vez de fallar, cargar la tienda "default"; nunca muestra los datos de otra tienda.
248
+
249
+ **Aislamiento de datos, marca, persona y productos entre tiendas**
250
+ > En la TIENDA A pregunta por SU política de envíos y SU catálogo; en la TIENDA B (con otros documentos y otra tienda Shopify) pregunta lo mismo.
251
+
252
+ ✅ Debería: cada tienda responde SOLO con lo suyo: marca, color, persona, bienvenida, documentos (RAG) y catálogo/carrito salen de su propio tenant y su propia tienda Shopify. La Tienda A nunca ve lo de la B (ni al revés), y el secreto de Shopify de cada tienda se guarda cifrado.
253
+
254
+ **Mensaje de bienvenida personalizado**
255
+ > Abre el chat por primera vez
256
+
257
+ ✅ Debería: aparecer tu mensaje de bienvenida configurado como primer mensaje del bot.
258
+
259
+ **Nombre y avatar del asistente**
260
+ > Mira la cabecera del chat y las respuestas del bot
261
+
262
+ ✅ Debería: verse el nombre del asistente que pusiste y su avatar redondo (o un icono profesional de agente si no pusiste foto).
263
+
264
+ **Color de marca**
265
+ > Fíjate en el botón flotante, la cabecera y los botones
266
+
267
+ ✅ Debería: usar tu color de marca en el botón, cabecera, burbujas tuyas y botones.
268
+
269
+ **Continuidad de la conversación**
270
+ > Escribe un par de mensajes, recarga la página y sigue escribiendo
271
+
272
+ ✅ Debería: mantener el mismo chat y su historial tras recargar (cada tienda guarda su propia sesión por separado).
app/admin_ui/index.html CHANGED
@@ -113,6 +113,13 @@
113
  <button onclick="saveShopify()">Guardar Shopify</button>
114
  </div>
115
  <p class="muted" id="sh-state"></p>
 
 
 
 
 
 
 
116
  <h2 style="margin-top:14px;">Acciones que puede hacer el bot (opt-in)</h2>
117
  <p class="muted">El bot solo ejecuta estas acciones tras verificar la identidad del cliente. Actívalas bajo tu responsabilidad.</p>
118
  <label><input type="checkbox" id="act-cancel" style="width:auto;" /> Cancelar pedidos</label>
@@ -166,6 +173,8 @@
166
  $("assistant").value = t.assistant_name || ""; $("avatar").value = t.avatar_url || ""; $("welcome").value = t.welcome || "";
167
  $("sh-shop").value = t.shopify_shop || ""; $("sh-id").value = t.shopify_client_id || "";
168
  $("sh-state").textContent = t.has_shopify_secret ? "Secret guardado ✓" : "Sin secret (bot solo-info)";
 
 
169
  $("act-cancel").checked = !!t.allow_cancel; $("act-addr").checked = !!t.allow_address_change; $("act-ret").checked = !!t.allow_returns;
170
  loadSources(); loadStats();
171
  }
@@ -236,6 +245,13 @@
236
  await api("/tenants/" + slug(), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
237
  $("sh-secret").value = ""; flash("Shopify guardado."); onTenant();
238
  }
 
 
 
 
 
 
 
239
  $("token").addEventListener("change", loadTenants);
240
  </script>
241
  </body>
 
113
  <button onclick="saveShopify()">Guardar Shopify</button>
114
  </div>
115
  <p class="muted" id="sh-state"></p>
116
+ <h2 style="margin-top:14px;">WhatsApp (Meta Cloud API) — opcional</h2>
117
+ <div class="row">
118
+ <div><label>Phone Number ID</label><input id="wa-phone" placeholder="ID del número (Meta)" /></div>
119
+ <div><label>Access Token</label><input id="wa-token" type="password" placeholder="(se guarda cifrado)" /></div>
120
+ </div>
121
+ <button style="margin-top:8px;" onclick="saveWhatsapp()">Guardar WhatsApp</button>
122
+ <p class="muted" id="wa-state"></p>
123
  <h2 style="margin-top:14px;">Acciones que puede hacer el bot (opt-in)</h2>
124
  <p class="muted">El bot solo ejecuta estas acciones tras verificar la identidad del cliente. Actívalas bajo tu responsabilidad.</p>
125
  <label><input type="checkbox" id="act-cancel" style="width:auto;" /> Cancelar pedidos</label>
 
173
  $("assistant").value = t.assistant_name || ""; $("avatar").value = t.avatar_url || ""; $("welcome").value = t.welcome || "";
174
  $("sh-shop").value = t.shopify_shop || ""; $("sh-id").value = t.shopify_client_id || "";
175
  $("sh-state").textContent = t.has_shopify_secret ? "Secret guardado ✓" : "Sin secret (bot solo-info)";
176
+ $("wa-phone").value = t.whatsapp_phone_id || "";
177
+ $("wa-state").textContent = t.has_whatsapp_token ? "Token guardado ✓" : (t.whatsapp_phone_id ? "Sin token propio (usa el global)" : "Sin WhatsApp conectado");
178
  $("act-cancel").checked = !!t.allow_cancel; $("act-addr").checked = !!t.allow_address_change; $("act-ret").checked = !!t.allow_returns;
179
  loadSources(); loadStats();
180
  }
 
245
  await api("/tenants/" + slug(), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
246
  $("sh-secret").value = ""; flash("Shopify guardado."); onTenant();
247
  }
248
+ async function saveWhatsapp() {
249
+ var body = { whatsapp_phone_id: $("wa-phone").value.trim() };
250
+ var tok = $("wa-token").value.trim(); if (tok) body.whatsapp_token = tok;
251
+ await api("/tenants/" + slug(), { method: "PUT", headers: { "Content-Type": "application/json" },
252
+ body: JSON.stringify(body) });
253
+ $("wa-token").value = ""; flash("WhatsApp guardado."); onTenant();
254
+ }
255
  $("token").addEventListener("change", loadTenants);
256
  </script>
257
  </body>
app/config.py CHANGED
@@ -65,6 +65,16 @@ class Settings(BaseSettings):
65
  order_verify_lockout_seconds: int = 900
66
  session_retention_days: int = 30
67
 
 
 
 
 
 
 
 
 
 
 
68
  # Escalation (SMTP)
69
  support_email: str = ""
70
  smtp_host: str = ""
 
65
  order_verify_lockout_seconds: int = 900
66
  session_retention_days: int = 30
67
 
68
+ # WhatsApp (Meta Cloud API). verify_token: any string you also paste in the
69
+ # Meta webhook config. app_secret: the Meta app's secret, used to verify
70
+ # X-Hub-Signature-256 on incoming webhooks (enforced when set).
71
+ # access_token: global fallback for sending (a per-tenant token wins).
72
+ whatsapp_verify_token: str = ""
73
+ whatsapp_app_secret: str = ""
74
+ whatsapp_access_token: str = ""
75
+ whatsapp_api_version: str = "v21.0"
76
+ whatsapp_graph_base: str = "https://graph.facebook.com"
77
+
78
  # Escalation (SMTP)
79
  support_email: str = ""
80
  smtp_host: str = ""
app/main.py CHANGED
@@ -10,7 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware
10
 
11
  from app import db as dbmod
12
  from app.config import get_settings
13
- from app.routes import admin, chat, health, widget
14
 
15
  log = logging.getLogger(__name__)
16
 
@@ -66,6 +66,7 @@ def create_app() -> FastAPI:
66
  app.include_router(widget.router)
67
  app.include_router(chat.router)
68
  app.include_router(admin.router)
 
69
  return app
70
 
71
 
 
10
 
11
  from app import db as dbmod
12
  from app.config import get_settings
13
+ from app.routes import admin, chat, health, whatsapp, widget
14
 
15
  log = logging.getLogger(__name__)
16
 
 
66
  app.include_router(widget.router)
67
  app.include_router(chat.router)
68
  app.include_router(admin.router)
69
+ app.include_router(whatsapp.router)
70
  return app
71
 
72
 
app/models.py CHANGED
@@ -82,6 +82,11 @@ class Tenant(Base):
82
  allow_cancel: Mapped[bool] = mapped_column(default=False)
83
  allow_address_change: Mapped[bool] = mapped_column(default=False)
84
  allow_returns: Mapped[bool] = mapped_column(default=False)
 
 
 
 
 
85
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
86
 
87
 
 
82
  allow_cancel: Mapped[bool] = mapped_column(default=False)
83
  allow_address_change: Mapped[bool] = mapped_column(default=False)
84
  allow_returns: Mapped[bool] = mapped_column(default=False)
85
+ # WhatsApp channel (Meta Cloud API): the business phone_number_id that maps
86
+ # incoming webhooks to this tenant, plus an optional per-tenant access token
87
+ # (encrypted; falls back to the global WHATSAPP_ACCESS_TOKEN setting).
88
+ whatsapp_phone_id: Mapped[str] = mapped_column(String(40), default="", index=True)
89
+ whatsapp_token_enc: Mapped[str] = mapped_column(Text, default="")
90
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
91
 
92
 
app/routes/admin.py CHANGED
@@ -59,9 +59,15 @@ async def get_tenants(db: AsyncSession = Depends(get_session)) -> list[TenantOut
59
  async def post_tenant(body: TenantIn, db: AsyncSession = Depends(get_session)) -> TenantOut:
60
  if await get_tenant_by_slug(db, body.slug) is not None:
61
  raise HTTPException(status_code=409, detail="slug already exists")
62
- fields = body.model_dump(exclude={"slug", "shopify_client_secret"}, exclude_none=True)
 
 
63
  tenant = await create_tenant(
64
- db, body.slug, shopify_client_secret=body.shopify_client_secret, **fields
 
 
 
 
65
  )
66
  await db.commit()
67
  return TenantOut.from_tenant(tenant)
@@ -72,9 +78,15 @@ async def put_tenant(
72
  slug: str, body: TenantUpdate, db: AsyncSession = Depends(get_session)
73
  ) -> TenantOut:
74
  tenant = await _require_tenant(db, slug)
75
- fields = body.model_dump(exclude={"shopify_client_secret"}, exclude_none=True)
 
 
76
  await update_tenant(
77
- db, tenant, shopify_client_secret=body.shopify_client_secret, **fields
 
 
 
 
78
  )
79
  await db.commit()
80
  return TenantOut.from_tenant(tenant)
 
59
  async def post_tenant(body: TenantIn, db: AsyncSession = Depends(get_session)) -> TenantOut:
60
  if await get_tenant_by_slug(db, body.slug) is not None:
61
  raise HTTPException(status_code=409, detail="slug already exists")
62
+ fields = body.model_dump(
63
+ exclude={"slug", "shopify_client_secret", "whatsapp_token"}, exclude_none=True
64
+ )
65
  tenant = await create_tenant(
66
+ db,
67
+ body.slug,
68
+ shopify_client_secret=body.shopify_client_secret,
69
+ whatsapp_token=body.whatsapp_token,
70
+ **fields,
71
  )
72
  await db.commit()
73
  return TenantOut.from_tenant(tenant)
 
78
  slug: str, body: TenantUpdate, db: AsyncSession = Depends(get_session)
79
  ) -> TenantOut:
80
  tenant = await _require_tenant(db, slug)
81
+ fields = body.model_dump(
82
+ exclude={"shopify_client_secret", "whatsapp_token"}, exclude_none=True
83
+ )
84
  await update_tenant(
85
+ db,
86
+ tenant,
87
+ shopify_client_secret=body.shopify_client_secret,
88
+ whatsapp_token=body.whatsapp_token,
89
+ **fields,
90
  )
91
  await db.commit()
92
  return TenantOut.from_tenant(tenant)
app/routes/whatsapp.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """WhatsApp channel (Meta Cloud API webhook).
2
+
3
+ A second front door to the SAME conversation brain used by the web widget:
4
+ incoming customer texts run through run_turn() with full per-tenant isolation.
5
+ Tenant resolution: the business phone_number_id on the webhook → Tenant row.
6
+
7
+ - GET /whatsapp/webhook — Meta's one-time verification handshake.
8
+ - POST /whatsapp/webhook — incoming messages; signature-checked when the app
9
+ secret is configured; always answers 200 so Meta doesn't retry-storm
10
+ (real retries are deduped by message id).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ from datetime import UTC, datetime
18
+
19
+ from fastapi import APIRouter, Depends, HTTPException, Request
20
+ from fastapi.responses import PlainTextResponse
21
+ from sqlalchemy import select
22
+ from sqlalchemy.ext.asyncio import AsyncSession
23
+
24
+ from app import wa
25
+ from app.config import Settings, get_settings
26
+ from app.db import get_session
27
+ from app.deps import get_router
28
+ from app.models import ChatSession, Tenant
29
+ from app.orchestrator import run_turn
30
+ from app.tenancy import tenant_shopify_client, tenant_whatsapp_token
31
+ from app.tools.registry import ToolContext
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+ router = APIRouter()
36
+
37
+ _deduper = wa.Deduper()
38
+ _sender: wa.WhatsAppSender | None = None
39
+
40
+
41
+ def get_wa_sender(settings: Settings = Depends(get_settings)) -> wa.WhatsAppSender:
42
+ global _sender
43
+ if _sender is None:
44
+ _sender = wa.WhatsAppSender(
45
+ graph_base=settings.whatsapp_graph_base,
46
+ api_version=settings.whatsapp_api_version,
47
+ )
48
+ return _sender
49
+
50
+
51
+ @router.get("/whatsapp/webhook")
52
+ async def verify_webhook(
53
+ request: Request, settings: Settings = Depends(get_settings)
54
+ ) -> PlainTextResponse:
55
+ params = request.query_params
56
+ if (
57
+ params.get("hub.mode") == "subscribe"
58
+ and settings.whatsapp_verify_token
59
+ and params.get("hub.verify_token") == settings.whatsapp_verify_token
60
+ ):
61
+ return PlainTextResponse(params.get("hub.challenge") or "")
62
+ raise HTTPException(status_code=403, detail="verification failed")
63
+
64
+
65
+ @router.post("/whatsapp/webhook")
66
+ async def receive_webhook(
67
+ request: Request,
68
+ db: AsyncSession = Depends(get_session),
69
+ router_=Depends(get_router),
70
+ settings: Settings = Depends(get_settings),
71
+ sender: wa.WhatsAppSender = Depends(get_wa_sender),
72
+ ) -> dict[str, str]:
73
+ raw = await request.body()
74
+ if settings.whatsapp_app_secret and not wa.verify_signature(
75
+ settings.whatsapp_app_secret, raw, request.headers.get("X-Hub-Signature-256")
76
+ ):
77
+ raise HTTPException(status_code=403, detail="bad signature")
78
+
79
+ try:
80
+ payload = json.loads(raw or b"{}")
81
+ except ValueError:
82
+ return {"status": "ignored"}
83
+
84
+ for msg in wa.parse_incoming(payload):
85
+ if _deduper.seen(msg.wamid):
86
+ continue
87
+ try:
88
+ await _handle_message(db, router_, settings, sender, msg)
89
+ except Exception: # noqa: BLE001 - one bad message must not 500 the batch
90
+ log.exception("whatsapp message handling failed (wamid=%s)", msg.wamid)
91
+ return {"status": "ok"}
92
+
93
+
94
+ async def _handle_message(
95
+ db: AsyncSession,
96
+ router_,
97
+ settings: Settings,
98
+ sender: wa.WhatsAppSender,
99
+ msg: wa.IncomingMessage,
100
+ ) -> None:
101
+ tenant = (
102
+ await db.execute(
103
+ select(Tenant).where(Tenant.whatsapp_phone_id == msg.phone_number_id)
104
+ )
105
+ ).scalar_one_or_none()
106
+ if tenant is None:
107
+ log.warning("whatsapp message for unknown phone_number_id=%s", msg.phone_number_id)
108
+ return
109
+
110
+ # One stable conversation per (tenant, customer phone). We create the row
111
+ # with our deterministic id ourselves: get_or_create_session would mint a
112
+ # random id on first contact (and letting clients pick ids is web-unsafe,
113
+ # so we don't change its semantics).
114
+ sid = wa.session_id_for(tenant.id, msg.from_phone)
115
+ session = await db.get(ChatSession, sid)
116
+ if session is None or session.tenant_id != tenant.id:
117
+ session = ChatSession(id=sid, shop=tenant.shopify_shop, tenant_id=tenant.id)
118
+ db.add(session)
119
+ await db.flush()
120
+ else:
121
+ session.last_seen = datetime.now(UTC)
122
+ ctx = ToolContext(
123
+ db=db,
124
+ session=session,
125
+ shopify=tenant_shopify_client(tenant),
126
+ tenant_id=tenant.id,
127
+ shop=tenant.shopify_shop,
128
+ allow_cancel=tenant.allow_cancel,
129
+ allow_address_change=tenant.allow_address_change,
130
+ allow_returns=tenant.allow_returns,
131
+ support_email=settings.support_email,
132
+ now=datetime.now(UTC),
133
+ )
134
+ response = await run_turn(router_, ctx, msg.text, brand_name=tenant.brand_name)
135
+ await db.commit()
136
+
137
+ body = wa.to_whatsapp_text(response.reply)
138
+ if response.products:
139
+ digest = wa.cards_to_text(response.products)
140
+ if digest:
141
+ body = f"{body}\n\n{digest}"
142
+ token = tenant_whatsapp_token(tenant) or settings.whatsapp_access_token
143
+ if not token:
144
+ log.error("no whatsapp access token for tenant %s; reply dropped", tenant.slug)
145
+ return
146
+ await sender.send_text(msg.phone_number_id, msg.from_phone, body, token=token)
app/schemas.py CHANGED
@@ -58,6 +58,8 @@ class TenantIn(BaseModel):
58
  allow_cancel: bool | None = None
59
  allow_address_change: bool | None = None
60
  allow_returns: bool | None = None
 
 
61
 
62
 
63
  class TenantUpdate(BaseModel):
@@ -75,6 +77,8 @@ class TenantUpdate(BaseModel):
75
  allow_cancel: bool | None = None
76
  allow_address_change: bool | None = None
77
  allow_returns: bool | None = None
 
 
78
 
79
 
80
  class TenantOut(BaseModel):
@@ -94,6 +98,8 @@ class TenantOut(BaseModel):
94
  allow_cancel: bool
95
  allow_address_change: bool
96
  allow_returns: bool
 
 
97
 
98
  @classmethod
99
  def from_tenant(cls, t) -> TenantOut:
@@ -114,4 +120,6 @@ class TenantOut(BaseModel):
114
  allow_cancel=t.allow_cancel,
115
  allow_address_change=t.allow_address_change,
116
  allow_returns=t.allow_returns,
 
 
117
  )
 
58
  allow_cancel: bool | None = None
59
  allow_address_change: bool | None = None
60
  allow_returns: bool | None = None
61
+ whatsapp_phone_id: str | None = None
62
+ whatsapp_token: str | None = None
63
 
64
 
65
  class TenantUpdate(BaseModel):
 
77
  allow_cancel: bool | None = None
78
  allow_address_change: bool | None = None
79
  allow_returns: bool | None = None
80
+ whatsapp_phone_id: str | None = None
81
+ whatsapp_token: str | None = None
82
 
83
 
84
  class TenantOut(BaseModel):
 
98
  allow_cancel: bool
99
  allow_address_change: bool
100
  allow_returns: bool
101
+ whatsapp_phone_id: str
102
+ has_whatsapp_token: bool
103
 
104
  @classmethod
105
  def from_tenant(cls, t) -> TenantOut:
 
120
  allow_cancel=t.allow_cancel,
121
  allow_address_change=t.allow_address_change,
122
  allow_returns=t.allow_returns,
123
+ whatsapp_phone_id=t.whatsapp_phone_id,
124
+ has_whatsapp_token=bool(t.whatsapp_token_enc),
125
  )
app/tenancy.py CHANGED
@@ -54,16 +54,24 @@ _TENANT_FIELDS = {
54
  "assistant_name",
55
  "avatar_url",
56
  "name",
 
57
  }
58
 
59
 
60
  async def create_tenant(
61
- db: AsyncSession, slug: str, *, shopify_client_secret: str | None = None, **fields: Any
 
 
 
 
 
62
  ) -> Tenant:
63
  data = {k: v for k, v in fields.items() if k in _TENANT_FIELDS and v is not None}
64
  tenant = Tenant(slug=slug, **data)
65
  if shopify_client_secret:
66
  tenant.shopify_client_secret_enc = encrypt(shopify_client_secret)
 
 
67
  db.add(tenant)
68
  await db.flush()
69
  _registry.invalidate(tenant.id)
@@ -71,7 +79,12 @@ async def create_tenant(
71
 
72
 
73
  async def update_tenant(
74
- db: AsyncSession, tenant: Tenant, *, shopify_client_secret: str | None = None, **fields: Any
 
 
 
 
 
75
  ) -> Tenant:
76
  for k, v in fields.items():
77
  if k in _TENANT_FIELDS and v is not None:
@@ -80,11 +93,18 @@ async def update_tenant(
80
  tenant.shopify_client_secret_enc = (
81
  encrypt(shopify_client_secret) if shopify_client_secret else ""
82
  )
 
 
83
  await db.flush()
84
  _registry.invalidate(tenant.id)
85
  return tenant
86
 
87
 
 
 
 
 
 
88
  class _ShopifyRegistry:
89
  """Caches one ShopifyGraphQLClient per tenant (keyed by creds fingerprint)."""
90
 
 
54
  "assistant_name",
55
  "avatar_url",
56
  "name",
57
+ "whatsapp_phone_id",
58
  }
59
 
60
 
61
  async def create_tenant(
62
+ db: AsyncSession,
63
+ slug: str,
64
+ *,
65
+ shopify_client_secret: str | None = None,
66
+ whatsapp_token: str | None = None,
67
+ **fields: Any,
68
  ) -> Tenant:
69
  data = {k: v for k, v in fields.items() if k in _TENANT_FIELDS and v is not None}
70
  tenant = Tenant(slug=slug, **data)
71
  if shopify_client_secret:
72
  tenant.shopify_client_secret_enc = encrypt(shopify_client_secret)
73
+ if whatsapp_token:
74
+ tenant.whatsapp_token_enc = encrypt(whatsapp_token)
75
  db.add(tenant)
76
  await db.flush()
77
  _registry.invalidate(tenant.id)
 
79
 
80
 
81
  async def update_tenant(
82
+ db: AsyncSession,
83
+ tenant: Tenant,
84
+ *,
85
+ shopify_client_secret: str | None = None,
86
+ whatsapp_token: str | None = None,
87
+ **fields: Any,
88
  ) -> Tenant:
89
  for k, v in fields.items():
90
  if k in _TENANT_FIELDS and v is not None:
 
93
  tenant.shopify_client_secret_enc = (
94
  encrypt(shopify_client_secret) if shopify_client_secret else ""
95
  )
96
+ if whatsapp_token is not None:
97
+ tenant.whatsapp_token_enc = encrypt(whatsapp_token) if whatsapp_token else ""
98
  await db.flush()
99
  _registry.invalidate(tenant.id)
100
  return tenant
101
 
102
 
103
+ def tenant_whatsapp_token(tenant: Tenant) -> str:
104
+ """The tenant's decrypted WhatsApp access token, or "" if not set."""
105
+ return decrypt(tenant.whatsapp_token_enc) if tenant.whatsapp_token_enc else ""
106
+
107
+
108
  class _ShopifyRegistry:
109
  """Caches one ShopifyGraphQLClient per tenant (keyed by creds fingerprint)."""
110
 
app/wa.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """WhatsApp (Meta Cloud API) channel helpers.
2
+
3
+ This module is the only WhatsApp-specific code: webhook signature check,
4
+ payload parsing, reply formatting, deterministic per-phone sessions, webhook
5
+ retry dedupe, and the Graph API sender. The conversation brain (orchestrator,
6
+ tools, prompts) is reused untouched.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import hmac
13
+ import logging
14
+ import re
15
+ from collections import OrderedDict
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ import httpx
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+
24
+ # ── webhook signature ────────────────────────────────────────────────────
25
+
26
+ def verify_signature(app_secret: str, raw_body: bytes, header: str | None) -> bool:
27
+ """Validate Meta's X-Hub-Signature-256 header (sha256=<hmac hex>)."""
28
+ if not header or not header.startswith("sha256="):
29
+ return False
30
+ expected = hmac.new(app_secret.encode(), raw_body, hashlib.sha256).hexdigest()
31
+ return hmac.compare_digest(header[len("sha256="):], expected)
32
+
33
+
34
+ # ── incoming payload parsing ─────────────────────────────────────────────
35
+
36
+ @dataclass
37
+ class IncomingMessage:
38
+ phone_number_id: str # the BUSINESS number that received it (→ tenant)
39
+ wamid: str # unique message id (dedupe key for Meta retries)
40
+ from_phone: str # customer's phone in international format
41
+ text: str
42
+
43
+
44
+ def parse_incoming(payload: dict[str, Any]) -> list[IncomingMessage]:
45
+ """Extract text messages from a webhook payload; ignore statuses/media."""
46
+ out: list[IncomingMessage] = []
47
+ for entry in payload.get("entry") or []:
48
+ for change in entry.get("changes") or []:
49
+ value = change.get("value") or {}
50
+ phone_number_id = (value.get("metadata") or {}).get("phone_number_id") or ""
51
+ for msg in value.get("messages") or []:
52
+ if msg.get("type") != "text":
53
+ continue # v1: text only (media/audio out of scope)
54
+ body = ((msg.get("text") or {}).get("body") or "").strip()
55
+ if not (phone_number_id and msg.get("id") and msg.get("from") and body):
56
+ continue
57
+ out.append(
58
+ IncomingMessage(
59
+ phone_number_id=phone_number_id,
60
+ wamid=msg["id"],
61
+ from_phone=msg["from"],
62
+ text=body,
63
+ )
64
+ )
65
+ return out
66
+
67
+
68
+ # ── sessions: one stable conversation per (tenant, customer phone) ──────
69
+
70
+ def session_id_for(tenant_id: int, phone: str) -> str:
71
+ """Deterministic ChatSession id (≤40 chars) so a customer's WhatsApp chat
72
+ keeps its history and order verification across messages."""
73
+ digest = hashlib.md5(f"wa:{tenant_id}:{phone}".encode()).hexdigest()
74
+ return f"wa{digest}" # 34 chars, fits String(40)
75
+
76
+
77
+ # ── webhook retry dedupe ─────────────────────────────────────────────────
78
+
79
+ class Deduper:
80
+ """Remember recently-seen message ids (Meta retries webhooks on timeout)."""
81
+
82
+ def __init__(self, maxlen: int = 2048) -> None:
83
+ self._seen: OrderedDict[str, None] = OrderedDict()
84
+ self._maxlen = maxlen
85
+
86
+ def seen(self, key: str) -> bool:
87
+ """True if key was already seen; otherwise records it and returns False."""
88
+ if key in self._seen:
89
+ return True
90
+ self._seen[key] = None
91
+ if len(self._seen) > self._maxlen:
92
+ self._seen.popitem(last=False)
93
+ return False
94
+
95
+
96
+ # ── reply formatting: chat-markdown → WhatsApp ───────────────────────────
97
+
98
+ _BOLD = re.compile(r"\*\*(.+?)\*\*", re.DOTALL)
99
+ _MD_LINK = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)")
100
+ _HEADING = re.compile(r"^#{1,6}\s*", re.MULTILINE)
101
+
102
+
103
+ def to_whatsapp_text(text: str) -> str:
104
+ """WhatsApp uses *bold* (single asterisks) and plain URLs, no md links."""
105
+ text = _BOLD.sub(r"*\1*", text)
106
+ text = _MD_LINK.sub(r"\1: \2", text)
107
+ text = _HEADING.sub("", text)
108
+ return text.strip()
109
+
110
+
111
+ def cards_to_text(cards: list[dict[str, Any]], limit: int = 3) -> str:
112
+ """Compact product-card digest to append after the reply (links work in WA)."""
113
+ lines: list[str] = []
114
+ for card in cards[:limit]:
115
+ title = card.get("title") or "Producto"
116
+ price = card.get("price") or ""
117
+ line = f"• *{title}*" + (f" — {price}" if price else "")
118
+ if card.get("add_url"):
119
+ line += f"\n Añadir al carrito: {card['add_url']}"
120
+ lines.append(line)
121
+ return "\n".join(lines)
122
+
123
+
124
+ # ── outbound sender (Graph API) ──────────────────────────────────────────
125
+
126
+ class WhatsAppSender:
127
+ """Thin Graph API client; one instance app-wide (tests inject a fake)."""
128
+
129
+ def __init__(self, *, graph_base: str, api_version: str) -> None:
130
+ self._base = graph_base.rstrip("/")
131
+ self._version = api_version
132
+
133
+ async def send_text(
134
+ self, phone_number_id: str, to: str, body: str, *, token: str
135
+ ) -> bool:
136
+ url = f"{self._base}/{self._version}/{phone_number_id}/messages"
137
+ payload = {
138
+ "messaging_product": "whatsapp",
139
+ "to": to,
140
+ "type": "text",
141
+ "text": {"body": body[:4096], "preview_url": True},
142
+ }
143
+ try:
144
+ async with httpx.AsyncClient(timeout=20.0) as client:
145
+ resp = await client.post(
146
+ url, json=payload, headers={"Authorization": f"Bearer {token}"}
147
+ )
148
+ if resp.status_code >= 400:
149
+ log.error("whatsapp send failed %s: %s", resp.status_code, resp.text[:300])
150
+ return False
151
+ return True
152
+ except httpx.HTTPError:
153
+ log.exception("whatsapp send error")
154
+ return False
migrations/versions/0005_whatsapp.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """WhatsApp channel: tenant phone_number_id + encrypted access token
2
+
3
+ Revision ID: 0005_whatsapp
4
+ Revises: 0004_order_actions
5
+ Create Date: 2026-06-10
6
+
7
+ Additive, idempotent on Postgres. No-op elsewhere (tests use create_all).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from alembic import op
13
+
14
+ revision = "0005_whatsapp"
15
+ down_revision = "0004_order_actions"
16
+ branch_labels = None
17
+ depends_on = None
18
+
19
+
20
+ def upgrade() -> None:
21
+ if op.get_bind().dialect.name != "postgresql":
22
+ return
23
+ op.execute(
24
+ "ALTER TABLE tenants ADD COLUMN IF NOT EXISTS whatsapp_phone_id VARCHAR(40) NOT NULL DEFAULT ''"
25
+ )
26
+ op.execute(
27
+ "ALTER TABLE tenants ADD COLUMN IF NOT EXISTS whatsapp_token_enc TEXT NOT NULL DEFAULT ''"
28
+ )
29
+ op.execute(
30
+ "CREATE INDEX IF NOT EXISTS ix_tenants_whatsapp_phone_id ON tenants (whatsapp_phone_id)"
31
+ )
32
+
33
+
34
+ def downgrade() -> None:
35
+ if op.get_bind().dialect.name != "postgresql":
36
+ return
37
+ op.execute("DROP INDEX IF EXISTS ix_tenants_whatsapp_phone_id")
38
+ op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS whatsapp_token_enc")
39
+ op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS whatsapp_phone_id")
tests/routes/test_whatsapp.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+
7
+ import pytest
8
+ from sqlalchemy import func, select
9
+
10
+ from app.config import get_settings
11
+ from app.deps import get_router
12
+ from app.llm.base import ChatResult
13
+ from app.models import ChatMessage, ChatSession, Tenant
14
+ from app.routes import whatsapp as wa_route
15
+
16
+
17
+ class FakeRouter:
18
+ def __init__(self, reply="¡Hola! Soy **el bot**."):
19
+ self.reply = reply
20
+
21
+ async def chat(self, messages, tools, tier="large", temperature=None):
22
+ return ChatResult(content=self.reply, tool_calls=[], finish_reason="stop")
23
+
24
+
25
+ class FakeSender:
26
+ def __init__(self):
27
+ self.sent: list[dict] = []
28
+
29
+ async def send_text(self, phone_number_id, to, body, *, token):
30
+ self.sent.append(
31
+ {"phone_number_id": phone_number_id, "to": to, "body": body, "token": token}
32
+ )
33
+ return True
34
+
35
+
36
+ def _payload(phone_number_id="111", wamid="wamid.A", frm="34600111222", text="hola"):
37
+ return {
38
+ "entry": [
39
+ {"changes": [{"value": {
40
+ "metadata": {"phone_number_id": phone_number_id},
41
+ "messages": [{"id": wamid, "from": frm, "type": "text",
42
+ "text": {"body": text}}],
43
+ }}]}
44
+ ]
45
+ }
46
+
47
+
48
+ @pytest.fixture(autouse=True)
49
+ def fresh_deduper():
50
+ wa_route._deduper = wa_route.wa.Deduper()
51
+ yield
52
+
53
+
54
+ @pytest.fixture
55
+ def wa_app(app_client, monkeypatch):
56
+ app, client = app_client
57
+ app.dependency_overrides[get_router] = lambda: FakeRouter()
58
+ sender = FakeSender()
59
+ app.dependency_overrides[wa_route.get_wa_sender] = lambda: sender
60
+ settings = get_settings()
61
+ monkeypatch.setattr(settings, "whatsapp_verify_token", "verif-123")
62
+ monkeypatch.setattr(settings, "whatsapp_app_secret", "")
63
+ monkeypatch.setattr(settings, "whatsapp_access_token", "global-token")
64
+ return app, client, sender
65
+
66
+
67
+ async def _make_wa_tenant(db, slug="watienda", phone_id="111"):
68
+ t = Tenant(slug=slug, brand_name="Mi Tienda", whatsapp_phone_id=phone_id)
69
+ db.add(t)
70
+ await db.flush()
71
+ return t
72
+
73
+
74
+ async def test_handshake_returns_challenge(wa_app):
75
+ _app, client, _ = wa_app
76
+ r = await client.get(
77
+ "/whatsapp/webhook",
78
+ params={"hub.mode": "subscribe", "hub.verify_token": "verif-123",
79
+ "hub.challenge": "42"},
80
+ )
81
+ assert r.status_code == 200
82
+ assert r.text == "42"
83
+
84
+
85
+ async def test_handshake_rejects_bad_token(wa_app):
86
+ _app, client, _ = wa_app
87
+ r = await client.get(
88
+ "/whatsapp/webhook",
89
+ params={"hub.mode": "subscribe", "hub.verify_token": "nope", "hub.challenge": "42"},
90
+ )
91
+ assert r.status_code == 403
92
+
93
+
94
+ async def test_message_runs_turn_and_replies(wa_app, db_session):
95
+ _app, client, sender = wa_app
96
+ await _make_wa_tenant(db_session)
97
+
98
+ r = await client.post("/whatsapp/webhook", json=_payload(text="hola tienda"))
99
+ assert r.status_code == 200
100
+ assert len(sender.sent) == 1
101
+ out = sender.sent[0]
102
+ assert out["to"] == "34600111222"
103
+ assert out["phone_number_id"] == "111"
104
+ assert "*el bot*" in out["body"] # markdown converted for WhatsApp
105
+ assert "**" not in out["body"]
106
+ assert out["token"] == "global-token" # falls back to the global token
107
+
108
+ # The turn was persisted under the tenant (same brain as the widget).
109
+ n = (await db_session.execute(select(func.count(ChatMessage.id)))).scalar_one()
110
+ assert n == 2 # user + assistant
111
+
112
+
113
+ async def test_same_phone_reuses_session(wa_app, db_session):
114
+ _app, client, _sender = wa_app
115
+ await _make_wa_tenant(db_session)
116
+
117
+ await client.post("/whatsapp/webhook", json=_payload(wamid="w1", text="hola"))
118
+ await client.post("/whatsapp/webhook", json=_payload(wamid="w2", text="¿envíos?"))
119
+
120
+ sessions = (await db_session.execute(select(ChatSession))).scalars().all()
121
+ assert len(sessions) == 1 # one stable conversation per phone
122
+
123
+
124
+ async def test_duplicate_wamid_processed_once(wa_app, db_session):
125
+ _app, client, sender = wa_app
126
+ await _make_wa_tenant(db_session)
127
+
128
+ await client.post("/whatsapp/webhook", json=_payload(wamid="dup"))
129
+ await client.post("/whatsapp/webhook", json=_payload(wamid="dup"))
130
+ assert len(sender.sent) == 1
131
+
132
+
133
+ async def test_unknown_phone_id_ignored(wa_app, db_session):
134
+ _app, client, sender = wa_app
135
+ await _make_wa_tenant(db_session, phone_id="999")
136
+
137
+ r = await client.post("/whatsapp/webhook", json=_payload(phone_number_id="111"))
138
+ assert r.status_code == 200 # never 500/404 back to Meta
139
+ assert sender.sent == []
140
+
141
+
142
+ async def test_statuses_only_payload_ignored(wa_app):
143
+ _app, client, sender = wa_app
144
+ payload = {"entry": [{"changes": [{"value": {
145
+ "metadata": {"phone_number_id": "111"},
146
+ "statuses": [{"id": "wamid.X", "status": "delivered"}],
147
+ }}]}]}
148
+ r = await client.post("/whatsapp/webhook", json=payload)
149
+ assert r.status_code == 200
150
+ assert sender.sent == []
151
+
152
+
153
+ async def test_signature_enforced_when_secret_set(wa_app, db_session, monkeypatch):
154
+ _app, client, sender = wa_app
155
+ await _make_wa_tenant(db_session)
156
+ settings = get_settings()
157
+ monkeypatch.setattr(settings, "whatsapp_app_secret", "app-secret")
158
+
159
+ body = json.dumps(_payload()).encode()
160
+ bad = await client.post(
161
+ "/whatsapp/webhook", content=body,
162
+ headers={"Content-Type": "application/json", "X-Hub-Signature-256": "sha256=bad"},
163
+ )
164
+ assert bad.status_code == 403
165
+ assert sender.sent == []
166
+
167
+ good_sig = "sha256=" + hmac.new(b"app-secret", body, hashlib.sha256).hexdigest()
168
+ ok = await client.post(
169
+ "/whatsapp/webhook", content=body,
170
+ headers={"Content-Type": "application/json", "X-Hub-Signature-256": good_sig},
171
+ )
172
+ assert ok.status_code == 200
173
+ assert len(sender.sent) == 1
174
+
175
+
176
+ async def test_tenant_isolation_two_stores(wa_app, db_session):
177
+ _app, client, sender = wa_app
178
+ a = await _make_wa_tenant(db_session, slug="store-a", phone_id="111")
179
+ b = await _make_wa_tenant(db_session, slug="store-b", phone_id="222")
180
+
181
+ await client.post("/whatsapp/webhook", json=_payload(phone_number_id="111", wamid="m1"))
182
+ await client.post("/whatsapp/webhook", json=_payload(phone_number_id="222", wamid="m2"))
183
+
184
+ sessions = (await db_session.execute(select(ChatSession))).scalars().all()
185
+ by_tenant = {s.tenant_id for s in sessions}
186
+ assert by_tenant == {a.id, b.id} # same customer phone, separate conversations
187
+ assert len(sender.sent) == 2
tests/test_onboarding_e2e.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end onboarding proof: a brand-new client, REAL extraction + REAL
2
+ embeddings (no index mock), answering only from ITS OWN uploaded document,
3
+ fully isolated from another client. This is the "can I onboard a real store
4
+ and trust it" guarantee.
5
+
6
+ Marked slow because it loads the embedding model.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ from app.models import ChatSession
17
+ from app.tenancy import get_tenant_by_slug
18
+ from app.tools import knowledge_tool
19
+ from app.tools.registry import ToolContext
20
+
21
+ AUTH = {"Authorization": "Bearer test-admin-token"}
22
+
23
+ # Distinctive facts with rare tokens so retrieval + isolation are unambiguous.
24
+ CLIENT_A = {
25
+ "slug": "mochilas-trekmax",
26
+ "brand": "Mochilas TrekMax",
27
+ "file": ("garantia.md", b"La garantia de la mochila TrekMax Summit es de siete anos. "
28
+ b"El material es Cordura 1000D totalmente impermeable.", "text/markdown"),
29
+ "ask": "cuanto dura la garantia de la mochila summit y de que material es",
30
+ "must_have": "TrekMax",
31
+ "must_not_have": "GotaFina",
32
+ }
33
+ CLIENT_B = {
34
+ "slug": "riego-gotafina",
35
+ "brand": "Riego GotaFina",
36
+ "file": ("kit.md", b"El kit de riego GotaFina incluye 30 metros de manguera de "
37
+ b"poliuretano y boquillas de laton macizo.", "text/markdown"),
38
+ "ask": "que incluye el kit de riego y de que son las boquillas",
39
+ "must_not_have": "TrekMax",
40
+ "must_have": "GotaFina",
41
+ }
42
+
43
+
44
+ async def _onboard(client, db, c):
45
+ # 1) Create the tenant with its brand (what the admin form does).
46
+ created = await client.post(
47
+ "/admin/tenants", headers=AUTH,
48
+ json={"slug": c["slug"], "brand_name": c["brand"]},
49
+ )
50
+ assert created.status_code == 201, created.text
51
+ assert created.json()["brand_name"] == c["brand"]
52
+ assert created.json()["has_shopify_secret"] is False
53
+
54
+ # 2) Set its Shopify credentials — secret is stored encrypted, never echoed.
55
+ upd = await client.put(
56
+ f"/admin/tenants/{c['slug']}", headers=AUTH,
57
+ json={"shopify_shop": f"{c['slug']}.myshopify.com",
58
+ "shopify_client_id": "id123",
59
+ "shopify_client_secret": "shpss_super_secret"},
60
+ )
61
+ assert upd.status_code == 200
62
+ assert upd.json()["has_shopify_secret"] is True
63
+
64
+ # 3) Upload its real document — REAL extract -> chunk -> embed -> store.
65
+ up = await client.post(
66
+ f"/admin/tenants/{c['slug']}/sources/file", headers=AUTH,
67
+ files={"file": c["file"]},
68
+ )
69
+ assert up.status_code == 201, up.text
70
+ assert up.json()["status"] == "indexed", up.json()
71
+ assert up.json().get("error") in (None, "")
72
+
73
+ tenant = await get_tenant_by_slug(db, c["slug"])
74
+ return tenant.id
75
+
76
+
77
+ @pytest.mark.slow
78
+ async def test_full_client_onboarding_and_isolation(app_client, db_session):
79
+ _app, client = app_client
80
+ try:
81
+ a_id = await _onboard(client, db_session, CLIENT_A)
82
+ b_id = await _onboard(client, db_session, CLIENT_B)
83
+
84
+ async def ask(tenant_id, question):
85
+ ctx = ToolContext(
86
+ db=db_session,
87
+ session=ChatSession(shop="x", tenant_id=tenant_id),
88
+ tenant_id=tenant_id,
89
+ )
90
+ return (await knowledge_tool.run({"query": question}, ctx))["context"]
91
+
92
+ # Client A's bot answers from A's doc, and NEVER sees B's.
93
+ a_ctx = await ask(a_id, CLIENT_A["ask"])
94
+ assert CLIENT_A["must_have"] in a_ctx, a_ctx
95
+ assert CLIENT_A["must_not_have"] not in a_ctx, f"LEAK from other tenant: {a_ctx}"
96
+
97
+ # Client B's bot answers from B's doc, and NEVER sees A's.
98
+ b_ctx = await ask(b_id, CLIENT_B["ask"])
99
+ assert CLIENT_B["must_have"] in b_ctx, b_ctx
100
+ assert CLIENT_B["must_not_have"] not in b_ctx, f"LEAK from other tenant: {b_ctx}"
101
+ finally:
102
+ shutil.rmtree(Path("uploads") / CLIENT_A["slug"], ignore_errors=True)
103
+ shutil.rmtree(Path("uploads") / CLIENT_B["slug"], ignore_errors=True)
tests/test_wa_format.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+
6
+ from app.wa import (
7
+ Deduper,
8
+ cards_to_text,
9
+ parse_incoming,
10
+ session_id_for,
11
+ to_whatsapp_text,
12
+ verify_signature,
13
+ )
14
+
15
+
16
+ def _sig(secret: str, body: bytes) -> str:
17
+ return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
18
+
19
+
20
+ def test_verify_signature_roundtrip():
21
+ body = b'{"hola": 1}'
22
+ assert verify_signature("s3cret", body, _sig("s3cret", body)) is True
23
+ assert verify_signature("s3cret", body, _sig("wrong", body)) is False
24
+ assert verify_signature("s3cret", body, None) is False
25
+ assert verify_signature("s3cret", body, "md5=abc") is False
26
+
27
+
28
+ def test_to_whatsapp_text_converts_markdown():
29
+ md = "**Manguera plana** de PVC\n\n- [Ver carrito](https://x.com/cart/1:1)\n## Detalles"
30
+ out = to_whatsapp_text(md)
31
+ assert "*Manguera plana*" in out
32
+ assert "**" not in out
33
+ assert "Ver carrito: https://x.com/cart/1:1" in out
34
+ assert "##" not in out
35
+
36
+
37
+ def test_cards_to_text_caps_and_links():
38
+ cards = [
39
+ {"title": f"Prod {i}", "price": "9,99 €", "add_url": f"https://s/cart/{i}:1"}
40
+ for i in range(5)
41
+ ]
42
+ out = cards_to_text(cards)
43
+ assert out.count("•") == 3 # capped
44
+ assert "*Prod 0*" in out and "https://s/cart/0:1" in out
45
+
46
+
47
+ def test_session_id_deterministic_and_fits_column():
48
+ a = session_id_for(1, "34600111222")
49
+ assert a == session_id_for(1, "34600111222") # stable per phone
50
+ assert a != session_id_for(2, "34600111222") # tenant-scoped
51
+ assert a != session_id_for(1, "34600999888") # phone-scoped
52
+ assert len(a) <= 40 and a.startswith("wa")
53
+
54
+
55
+ def test_deduper_remembers_and_evicts():
56
+ d = Deduper(maxlen=2)
57
+ assert d.seen("a") is False
58
+ assert d.seen("a") is True
59
+ d.seen("b"), d.seen("c") # evicts "a"
60
+ assert d.seen("a") is False
61
+
62
+
63
+ def test_parse_incoming_extracts_text_and_skips_statuses():
64
+ payload = {
65
+ "entry": [
66
+ {
67
+ "changes": [
68
+ {
69
+ "value": {
70
+ "metadata": {"phone_number_id": "111"},
71
+ "messages": [
72
+ {"id": "wamid.1", "from": "34600111222", "type": "text",
73
+ "text": {"body": "hola"}},
74
+ {"id": "wamid.2", "from": "34600111222", "type": "image"},
75
+ ],
76
+ }
77
+ },
78
+ {"value": {"metadata": {"phone_number_id": "111"},
79
+ "statuses": [{"id": "wamid.1", "status": "delivered"}]}},
80
+ ]
81
+ }
82
+ ]
83
+ }
84
+ msgs = parse_incoming(payload)
85
+ assert len(msgs) == 1
86
+ assert msgs[0].wamid == "wamid.1"
87
+ assert msgs[0].phone_number_id == "111"
88
+ assert msgs[0].from_phone == "34600111222"
89
+ assert msgs[0].text == "hola"