feat: WhatsApp channel
Browse files- GUIA_WHATSAPP.md +76 -0
- PRUEBAS.md +272 -0
- app/admin_ui/index.html +16 -0
- app/config.py +10 -0
- app/main.py +2 -1
- app/models.py +5 -0
- app/routes/admin.py +16 -4
- app/routes/whatsapp.py +146 -0
- app/schemas.py +8 -0
- app/tenancy.py +22 -2
- app/wa.py +154 -0
- migrations/versions/0005_whatsapp.py +39 -0
- tests/routes/test_whatsapp.py +187 -0
- tests/test_onboarding_e2e.py +103 -0
- tests/test_wa_format.py +89 -0
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(
|
|
|
|
|
|
|
| 63 |
tenant = await create_tenant(
|
| 64 |
-
db,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 76 |
await update_tenant(
|
| 77 |
-
db,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|