up
This commit is contained in:
@@ -0,0 +1,916 @@
|
||||
# Documentación Técnica — Bot WhatsApp Cloud API
|
||||
|
||||
> **Propósito:** Esta documentación está pensada para ser entregada a otra IA o equipo de desarrollo para reimplementar el bot en cualquier lenguaje/plataforma, con toda la información necesaria sobre flujos, APIs, base de datos y contratos de datos.
|
||||
|
||||
---
|
||||
|
||||
## 1. Resumen del sistema
|
||||
|
||||
El sistema es un **bot de WhatsApp** basado en la **WhatsApp Cloud API (Meta/Facebook Graph API v22.0)**. Permite:
|
||||
|
||||
- Recibir y procesar mensajes entrantes mediante un **webhook HTTP**.
|
||||
- Responder automáticamente con textos, menús, botones interactivos, listas y archivos multimedia.
|
||||
- Transferir la conversación a un asesor humano.
|
||||
- Enviar **plantillas de mensaje** aprobadas por Meta.
|
||||
- Enviar **mensajes programados**.
|
||||
- Gestionar **términos y condiciones** (envío y aceptación).
|
||||
|
||||
**Lenguaje original:** PHP 8.x
|
||||
**Base de datos:** MySQL / MariaDB
|
||||
**Cola / Workers:** Worker PHP con cron (no RabbitMQ ni Redis, aunque se usa Redis como opción para rate-limit).
|
||||
|
||||
---
|
||||
|
||||
## 2. Credenciales y configuración requerida
|
||||
|
||||
Todas las credenciales se almacenan en la **tabla `system_config`** de la base de datos (no en código duro). También se cargan desde variables de entorno / archivo `.env`.
|
||||
|
||||
### 2.1 Variables de entorno (`.env`)
|
||||
|
||||
```
|
||||
DB_HOST=mysql # Host de la base de datos
|
||||
DB_PORT=3306
|
||||
DB_NAME=usite_whatsapp_bot
|
||||
DB_USER=usite_whatsapp_user
|
||||
DB_PASS=<password>
|
||||
DB_CHARSET=utf8mb4
|
||||
```
|
||||
|
||||
### 2.2 Configuración de WhatsApp (en tabla `system_config`)
|
||||
|
||||
| `config_key` | Descripción | Ejemplo |
|
||||
|----------------------|-----------------------------------------------------------------|--------------------------------------------------|
|
||||
| `whatsapp_token` | Token de acceso permanente de la App de Meta | `EAABz...` |
|
||||
| `phone_number_id` | ID del número de teléfono de la cuenta de WhatsApp Business | `123456789012345` |
|
||||
| `whatsapp_api_url` | URL base de la Graph API | `https://graph.facebook.com/v22.0/` |
|
||||
| `webhook_verify_token` | Token secreto para verificar el webhook (GET de Meta) | `mi_token_secreto` |
|
||||
| `bot_enabled` | Si el bot responde automáticamente (`1`/`0`) | `1` |
|
||||
| `business_hours_*` | Horarios de atención (ver sección 8) | — |
|
||||
| `terms_message` | Mensaje de términos y condiciones que se envía al usuario | texto libre |
|
||||
|
||||
---
|
||||
|
||||
## 3. API de WhatsApp consumida
|
||||
|
||||
**Base URL:** `https://graph.facebook.com/v22.0/{phone_number_id}/`
|
||||
|
||||
**Autenticación:** Header `Authorization: Bearer {whatsapp_token}`
|
||||
|
||||
**Formato:** JSON (Content-Type: `application/json`) salvo para subida de archivos (multipart/form-data).
|
||||
|
||||
### 3.1 Enviar mensajes — `POST /{phone_number_id}/messages`
|
||||
|
||||
Todos los tipos de mensaje tienen el campo común:
|
||||
|
||||
```json
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"to": "57300XXXXXXX"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3.1.1 Mensaje de texto
|
||||
|
||||
```json
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"to": "57300XXXXXXX",
|
||||
"type": "text",
|
||||
"text": {
|
||||
"body": "Hola, ¿en qué te podemos ayudar?"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3.1.2 Mensaje de plantilla (template)
|
||||
|
||||
```json
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"to": "57300XXXXXXX",
|
||||
"type": "template",
|
||||
"template": {
|
||||
"name": "nombre_plantilla",
|
||||
"language": { "code": "es" },
|
||||
"components": [
|
||||
{
|
||||
"type": "header",
|
||||
"parameters": [
|
||||
{ "type": "text", "text": "Valor del header" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "body",
|
||||
"parameters": [
|
||||
{ "type": "text", "parameter_name": "nombre", "text": "Juan" },
|
||||
{ "type": "text", "parameter_name": "fecha", "text": "20/04/2026" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Nota sobre parámetros nombrados vs posicionales:**
|
||||
> - Si la plantilla usa variables `{{nombre}}` (nombradas): incluir `"parameter_name": "nombre"`.
|
||||
> - Si la plantilla usa variables `{{1}}`, `{{2}}` (posicionales): omitir `parameter_name`, usar solo `"type": "text", "text": "valor"` en orden.
|
||||
|
||||
---
|
||||
|
||||
#### 3.1.3 Mensaje interactivo — Botones
|
||||
|
||||
```json
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": "57300XXXXXXX",
|
||||
"type": "interactive",
|
||||
"interactive": {
|
||||
"type": "button",
|
||||
"header": { "type": "text", "text": "Encabezado opcional" },
|
||||
"body": { "text": "Selecciona una opción:" },
|
||||
"footer": { "text": "Pie de página opcional" },
|
||||
"action": {
|
||||
"buttons": [
|
||||
{ "type": "reply", "reply": { "id": "btn_1", "title": "Opción 1" } },
|
||||
{ "type": "reply", "reply": { "id": "btn_2", "title": "Opción 2" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Máximo **3 botones**. El `title` no puede superar 20 caracteres.
|
||||
|
||||
---
|
||||
|
||||
#### 3.1.4 Mensaje interactivo — Lista
|
||||
|
||||
```json
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": "57300XXXXXXX",
|
||||
"type": "interactive",
|
||||
"interactive": {
|
||||
"type": "list",
|
||||
"body": { "text": "Elige una opción de la lista:" },
|
||||
"action": {
|
||||
"button": "Ver opciones",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Sección 1",
|
||||
"rows": [
|
||||
{ "id": "row_1", "title": "Opción 1", "description": "Descripción opcional" },
|
||||
{ "id": "row_2", "title": "Opción 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3.1.5 Mensaje de imagen
|
||||
|
||||
```json
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"to": "57300XXXXXXX",
|
||||
"type": "image",
|
||||
"image": {
|
||||
"link": "https://ejemplo.com/imagen.jpg",
|
||||
"caption": "Pie de foto opcional"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3.1.6 Mensaje de video
|
||||
|
||||
```json
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"to": "57300XXXXXXX",
|
||||
"type": "video",
|
||||
"video": {
|
||||
"link": "https://ejemplo.com/video.mp4",
|
||||
"caption": "Descripción del video"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3.1.7 Mensaje de audio
|
||||
|
||||
```json
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"to": "57300XXXXXXX",
|
||||
"type": "audio",
|
||||
"audio": {
|
||||
"link": "https://ejemplo.com/audio.mp3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Para notas de voz, el archivo debe ser `.ogg` con códec **OPUS** y subirse primero al media endpoint.
|
||||
|
||||
---
|
||||
|
||||
#### 3.1.8 Mensaje de documento
|
||||
|
||||
```json
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"to": "57300XXXXXXX",
|
||||
"type": "document",
|
||||
"document": {
|
||||
"link": "https://ejemplo.com/archivo.pdf",
|
||||
"filename": "resultados.pdf",
|
||||
"caption": "Tus resultados de laboratorio"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3.1.9 Marcar mensaje como leído — `POST /{phone_number_id}/conversations`
|
||||
|
||||
```json
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"status": "read",
|
||||
"message_id": "wamid.XXXXX"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3.1.10 Enviar reacción a un mensaje
|
||||
|
||||
```json
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"to": "57300XXXXXXX",
|
||||
"type": "reaction",
|
||||
"reaction": {
|
||||
"message_id": "wamid.XXXXX",
|
||||
"emoji": "👍"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Subir archivo multimedia — `POST /{phone_number_id}/media`
|
||||
|
||||
**Content-Type:** `multipart/form-data`
|
||||
|
||||
Campos del formulario:
|
||||
|
||||
| Campo | Valor |
|
||||
|---------------------|------------------------------------------|
|
||||
| `messaging_product` | `whatsapp` |
|
||||
| `file` | Archivo binario |
|
||||
| `type` | MIME type (ej: `image/jpeg`, `video/mp4`) |
|
||||
|
||||
**Respuesta exitosa:**
|
||||
```json
|
||||
{ "id": "1234567890123456" }
|
||||
```
|
||||
|
||||
Una vez obtenido el `id`, se puede usar en mensajes así:
|
||||
|
||||
```json
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"to": "57300XXXXXXX",
|
||||
"type": "image",
|
||||
"image": { "id": "1234567890123456" }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Obtener URL de un media — `GET /{media_id}`
|
||||
|
||||
**Respuesta:**
|
||||
```json
|
||||
{
|
||||
"url": "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=...",
|
||||
"mime_type": "image/jpeg",
|
||||
"sha256": "...",
|
||||
"file_size": 12345,
|
||||
"id": "1234567890123456",
|
||||
"messaging_product": "whatsapp"
|
||||
}
|
||||
```
|
||||
|
||||
Luego descargar esa URL con el mismo Bearer token.
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Gestión de plantillas — `GET /{waba_id}/message_templates`
|
||||
|
||||
```
|
||||
GET https://graph.facebook.com/v22.0/{WABA_ID}/message_templates
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
**Parámetros opcionales:** `?name=nombre_plantilla&status=APPROVED`
|
||||
|
||||
---
|
||||
|
||||
## 4. Webhook — Recepción de mensajes
|
||||
|
||||
### 4.1 Verificación del webhook (GET)
|
||||
|
||||
Meta envía un GET para verificar el endpoint:
|
||||
|
||||
```
|
||||
GET /api/webhook.php?hub.mode=subscribe&hub.verify_token=TOKEN&hub.challenge=CHALLENGE
|
||||
```
|
||||
|
||||
El sistema debe responder con el valor de `hub.challenge` si `hub.verify_token` coincide con `WEBHOOK_VERIFY_TOKEN`.
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Payload de mensaje entrante (POST)
|
||||
|
||||
```json
|
||||
{
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [
|
||||
{
|
||||
"id": "WABA_ID",
|
||||
"changes": [
|
||||
{
|
||||
"field": "messages",
|
||||
"value": {
|
||||
"messaging_product": "whatsapp",
|
||||
"metadata": {
|
||||
"display_phone_number": "57300XXXXXXX",
|
||||
"phone_number_id": "PHONE_NUMBER_ID"
|
||||
},
|
||||
"contacts": [
|
||||
{
|
||||
"profile": { "name": "Nombre del Contacto" },
|
||||
"wa_id": "57300XXXXXXX"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"from": "57300XXXXXXX",
|
||||
"id": "wamid.XXXXXXXXXXXXX",
|
||||
"timestamp": "1713900000",
|
||||
"type": "text",
|
||||
"text": { "body": "Hola" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Tipos de mensaje posibles en el campo `type`:**
|
||||
|
||||
| `type` | Estructura del mensaje |
|
||||
|--------------|-------------------------------------------------------------|
|
||||
| `text` | `message.text.body` |
|
||||
| `image` | `message.image.id`, `message.image.mime_type` |
|
||||
| `video` | `message.video.id`, `message.video.mime_type` |
|
||||
| `audio` | `message.audio.id`, `message.audio.voice` (bool) |
|
||||
| `document` | `message.document.id`, `document.filename`, `document.mime_type` |
|
||||
| `sticker` | `message.sticker.id` |
|
||||
| `location` | `message.location.latitude`, `message.location.longitude` |
|
||||
| `interactive`| `message.interactive.type` = `list_reply` o `button_reply` |
|
||||
| `reaction` | `message.reaction.emoji`, `message.reaction.message_id` |
|
||||
|
||||
**Respuesta de acceso interactivo:**
|
||||
|
||||
```json
|
||||
{
|
||||
"interactive": {
|
||||
"type": "list_reply",
|
||||
"list_reply": {
|
||||
"id": "row_1",
|
||||
"title": "Opción 1",
|
||||
"description": "descripción"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Normalización interna de mensajes interactivos
|
||||
|
||||
El webhook normaliza las respuestas de lista/botón a texto plano para que el BotService las procese como si fueran texto:
|
||||
|
||||
- Si `title` empieza con un número (`"1. Opción"`), se extrae solo el número.
|
||||
- Si no, se usa el `title` completo.
|
||||
|
||||
---
|
||||
|
||||
## 5. Flujo de procesamiento de mensajes
|
||||
|
||||
```
|
||||
WhatsApp Cloud API
|
||||
│
|
||||
▼
|
||||
POST /api/webhook.php
|
||||
│
|
||||
├─ Verificar duplicado (por message_id en conversations)
|
||||
├─ Obtener/crear usuario en tabla `users`
|
||||
├─ Guardar nombre del contacto si no tenía
|
||||
│
|
||||
▼
|
||||
WhatsAppWebhook::processConversations()
|
||||
│
|
||||
├─ Extraer tipo de mensaje y contenido
|
||||
├─ Guardar mensaje en tabla `conversations` (direction: incoming)
|
||||
│
|
||||
▼
|
||||
BotService::processMessage($user, $messageText, $messageType)
|
||||
│
|
||||
├─ 1. Adquirir advisory lock por usuario (MySQL GET_LOCK) → evita race conditions
|
||||
├─ 2. Si usuario bloqueado → descartar
|
||||
├─ 3. Si usuario in_service (atendido por asesor) → descartar
|
||||
├─ 4. maybeResetConversationAfterIdle() → reinicia si inactividad > 6h
|
||||
├─ 5. Flujo de Términos:
|
||||
│ ├─ Si terms_pending → processTermsResponse()
|
||||
│ └─ Si needsTermsAcceptance() → sendTermsMessage()
|
||||
├─ 6. Si 'isNewUser' → sendWelcomeMessage()
|
||||
├─ 7. Si usuario escribe "enviado/a/os/as" → requestAdvisor()
|
||||
├─ 8. processSpecialCommands() (MENU, ATRÁS, ASESOR, etc.)
|
||||
├─ 9. Si on_hold activo y no expiró → notificar y retornar
|
||||
├─10. Si advisor_requested activo → solo navegación explícita
|
||||
├─11. Si bot_paused_until activo → solo navegación explícita
|
||||
├─12. Si bot_enabled=false → solo comandos explícitos
|
||||
├─13. Si current_menu_id → processMenuSelection()
|
||||
└─14. Fallback → sendDefaultNoMatch()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Comandos especiales del bot
|
||||
|
||||
Los usuarios pueden escribir estas palabras clave en cualquier momento:
|
||||
|
||||
| Palabra clave | Acción |
|
||||
|----------------------------------------|--------------------------------------------|
|
||||
| `menu`, `menú`, `inicio` | Mostrar menú principal |
|
||||
| `atras`, `atrás`, `volver`, `back` | Retroceder al menú anterior |
|
||||
| `asesor`, `agente`, `humano` | Solicitar atención humana |
|
||||
| `enviado`, `enviada`, `enviados`, `enviadas` | Indicar que se envió documentación — transfiere a asesor |
|
||||
| Número (`1`, `2`, `3`…) | Seleccionar opción de menú activo |
|
||||
|
||||
---
|
||||
|
||||
## 7. Base de datos — Tablas principales del bot
|
||||
|
||||
### 7.1 `users` — Usuarios (contactos de WhatsApp)
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|---------------------|-----------------------|----------------------------------------------------|
|
||||
| `id` | INT AUTO_INCREMENT | Clave primaria |
|
||||
| `phone_number` | VARCHAR(20) UNIQUE | Número en formato internacional sin `+` (ej: `573001234567`) |
|
||||
| `name` | VARCHAR(100) | Nombre del contacto (viene del perfil de WhatsApp) |
|
||||
| `status` | ENUM | `active`, `blocked`, `inactive` |
|
||||
| `current_menu_id` | INT NULL | FK → `menus.id` — menú donde está el usuario |
|
||||
| `current_step` | INT DEFAULT 0 | Paso dentro del flujo actual |
|
||||
| `session_data` | TEXT (JSON) | Datos temporales de sesión |
|
||||
| `welcome_sent_at` | DATETIME | Última vez que se envió el mensaje de bienvenida |
|
||||
| `in_service` | TINYINT(1) | `1` = está siendo atendido por un asesor |
|
||||
| `in_service_by` | INT NULL | FK → `admin_users.id` — asesor que lo atiende |
|
||||
| `on_hold` | TINYINT(1) | `1` = el asesor lo puso en espera |
|
||||
| `bot_paused_until` | DATETIME NULL | El bot está pausado hasta esta fecha/hora |
|
||||
| `advisor_requested` | TINYINT(1) | `1` = el usuario solicitó asesor |
|
||||
| `bot_enabled` | TINYINT(1) DEFAULT 1 | Si el bot responde a este usuario |
|
||||
| `terms_pending` | TINYINT(1) | `1` = esperando respuesta de términos |
|
||||
| `terms_accepted_at` | DATETIME | Cuándo aceptó los términos |
|
||||
| `terms_version_id` | INT NULL | FK → `terms_versions.id` |
|
||||
| `created_at` | TIMESTAMP | Fecha de creación |
|
||||
| `updated_at` | TIMESTAMP | Última actualización |
|
||||
|
||||
---
|
||||
|
||||
### 7.2 `conversations` — Mensajes
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|--------------------------|-----------------|-----------------------------------------------------------|
|
||||
| `id` | INT | Clave primaria |
|
||||
| `user_id` | INT | FK → `users.id` |
|
||||
| `message_id` | VARCHAR(255) | ID único del mensaje en WhatsApp (`wamid.XXX`) |
|
||||
| `reply_to_message_id` | VARCHAR(255) | ID del mensaje al que responde |
|
||||
| `reaction_to_message_id` | VARCHAR(255) | ID del mensaje al que se reaccionó |
|
||||
| `reaction_emoji` | VARCHAR(64) | Emoji de la reacción |
|
||||
| `direction` | ENUM | `incoming` (usuario→bot) / `outgoing` (bot→usuario) |
|
||||
| `message_type` | VARCHAR(32) | `text`, `image`, `video`, `audio`, `document`, `reaction`|
|
||||
| `content` | TEXT | Contenido del mensaje (texto o JSON para multimedia/sistema) |
|
||||
| `media_url` | TEXT | URL pública del archivo |
|
||||
| `whatsapp_media_id` | VARCHAR(255) | ID del media en WhatsApp |
|
||||
| `local_file` | VARCHAR(255) | Ruta local del archivo descargado |
|
||||
| `local_thumb` | VARCHAR(255) | Ruta local del thumbnail |
|
||||
| `media_storage` | VARCHAR(50) | Dónde se almacena (`local`, `whatsapp`, etc.) |
|
||||
| `status` | VARCHAR(32) | `received`, `sent`, `delivered`, `read`, `failed` |
|
||||
| `is_read` | TINYINT(1) | Si el admin marcó la conversación como leída |
|
||||
| `filename` | VARCHAR(255) | Nombre del archivo adjunto |
|
||||
| `mime_type` | VARCHAR(100) | MIME type del archivo |
|
||||
| `created_at` | TIMESTAMP | Fecha del mensaje |
|
||||
|
||||
---
|
||||
|
||||
### 7.3 `menus` — Menús del bot
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|-----------------|---------------|----------------------------------------------------------|
|
||||
| `id` | INT | Clave primaria |
|
||||
| `name` | VARCHAR(255) | Nombre interno del menú |
|
||||
| `title` | VARCHAR(255) | Título que se muestra al usuario |
|
||||
| `message` | TEXT | Texto del menú completo |
|
||||
| `parent_id` | INT NULL | FK → `menus.id` — menú padre (para navegación ATRÁS) |
|
||||
| `is_main` | TINYINT(1) | `1` = es el menú principal |
|
||||
| `is_active` | TINYINT(1) | `1` = activo |
|
||||
| `sort_order` | INT | Orden de presentación |
|
||||
| `created_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 7.4 `menu_options` — Opciones de menú
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|-----------------|---------------|----------------------------------------------------------------|
|
||||
| `id` | INT | Clave primaria |
|
||||
| `menu_id` | INT | FK → `menus.id` |
|
||||
| `option_number` | INT | Número que el usuario debe escribir (1, 2, 3…) |
|
||||
| `title` | VARCHAR(255) | Texto de la opción |
|
||||
| `action_type` | ENUM | `submenu`, `message`, `template`, `url`, `advisor`, `flow` |
|
||||
| `action_value` | TEXT | Depends en `action_type`: ID de submenú, texto, nombre de plantilla, URL |
|
||||
| `is_active` | TINYINT(1) | `1` = activa |
|
||||
| `sort_order` | INT | Orden |
|
||||
|
||||
---
|
||||
|
||||
### 7.5 `message_templates` — Plantillas
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|----------------------|---------------|----------------------------------------------------------------|
|
||||
| `id` | INT | Clave primaria |
|
||||
| `name` | VARCHAR(255) | Nombre interno |
|
||||
| `template_name` | VARCHAR(255) | Nombre en Meta (slug, sin espacios) |
|
||||
| `language_code` | VARCHAR(10) | Idioma (`es`, `en`, etc.) |
|
||||
| `status` | ENUM | `pending`, `approved`, `rejected`, `paused` |
|
||||
| `body_text` | TEXT | Cuerpo del template con variables `{{1}}` o `{{nombre}}` |
|
||||
| `header_type` | ENUM | `text`, `image`, `video`, `document` |
|
||||
| `header_text` | VARCHAR(255) | Texto del header si aplica |
|
||||
| `footer_text` | VARCHAR(255) | Pie del mensaje |
|
||||
| `components` | LONGTEXT JSON | Componentes completos del template tal como los devuelve Meta |
|
||||
| `example_parameters` | LONGTEXT JSON | Ejemplos de valores para las variables |
|
||||
| `created_at` | TIMESTAMP | |
|
||||
| `updated_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 7.6 `autoresponses` — Respuestas automáticas por keyword
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|-----------------|-|-----------------------------------------------------------------|
|
||||
| `id` | INT | Clave primaria |
|
||||
| `trigger_type` | ENUM | `keyword`, `contains`, `exact`, `welcome`, `default` |
|
||||
| `trigger_value` | TEXT | Palabras clave separadas por coma |
|
||||
| `response_text` | TEXT | Texto de la respuesta |
|
||||
| `response_type` | ENUM | `text`, `template`, `menu` |
|
||||
| `template_name` | VARCHAR(255) | Nombre de la plantilla si `response_type = template` |
|
||||
| `menu_id` | INT NULL | ID del menú si `response_type = menu` |
|
||||
| `priority` | INT | Prioridad (mayor número = mayor prioridad) |
|
||||
| `is_active` | TINYINT(1) | `1` = activa |
|
||||
|
||||
---
|
||||
|
||||
### 7.7 `scheduled_messages` — Mensajes programados
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|-----------------------|---------------|-----------------------------------------------------------|
|
||||
| `id` | INT | Clave primaria |
|
||||
| `user_id` | INT | FK → `users.id` |
|
||||
| `template_id` | INT NULL | FK → `message_templates.id` |
|
||||
| `template_name` | VARCHAR(100) | Nombre de la plantilla |
|
||||
| `template_language` | VARCHAR(10) | Idioma de la plantilla |
|
||||
| `template_parameters` | LONGTEXT JSON | Parámetros para las variables de la plantilla |
|
||||
| `message_type` | ENUM | `text`, `template` |
|
||||
| `message_content` | TEXT | Texto si `message_type = text` |
|
||||
| `scheduled_date` | DATE | Fecha programada |
|
||||
| `scheduled_time` | TIME | Hora programada |
|
||||
| `status` | ENUM | `pending`, `sent`, `failed`, `cancelled` |
|
||||
| `sent_at` | DATETIME | Cuándo se envió efectivamente |
|
||||
| `error_message` | TEXT | Error si falló el envío |
|
||||
| `created_by` | INT NULL | Admin que creó el recordatorio |
|
||||
| `created_at` | DATETIME | |
|
||||
|
||||
---
|
||||
|
||||
### 7.8 `system_config` — Configuración del sistema
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|----------------|---------------|------------------------------------|
|
||||
| `id` | INT | Clave primaria |
|
||||
| `config_key` | VARCHAR(255) | Clave de configuración (UNIQUE) |
|
||||
| `config_value` | TEXT | Valor de la configuración |
|
||||
| `description` | TEXT NULL | Descripción opcional |
|
||||
| `created_at` | DATETIME | |
|
||||
| `updated_at` | DATETIME | |
|
||||
|
||||
**Claves relevantes para el bot:**
|
||||
|
||||
| `config_key` | Descripción |
|
||||
|--------------------------|------------------------------------------------------|
|
||||
| `whatsapp_token` | Token de acceso Bearer |
|
||||
| `phone_number_id` | ID del número de teléfono |
|
||||
| `whatsapp_api_url` | URL base del API |
|
||||
| `webhook_verify_token` | Token secreto del webhook |
|
||||
| `bot_enabled` | Activar/desactivar el bot (`1`/`0`) |
|
||||
| `welcome_message` | Texto del mensaje de bienvenida |
|
||||
| `default_no_match` | Mensaje cuando no se entiende la entrada del usuario|
|
||||
| `advisor_message` | Mensaje al solicitar asesor |
|
||||
| `terms_message` | Texto de los términos y condiciones |
|
||||
| `business_hours_enabled` | Si se validan horarios de atención |
|
||||
| `business_hours_start` | Hora inicio (ej: `06:15`) |
|
||||
| `business_hours_end` | Hora fin (ej: `17:00`) |
|
||||
|
||||
---
|
||||
|
||||
### 7.9 `notifications` — Notificaciones internas para asesores
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------------|--------------|------------------------------------------|
|
||||
| `id` | INT | Clave primaria |
|
||||
| `user_id` | INT | FK → `users.id` |
|
||||
| `type` | VARCHAR(50) | Tipo de notificación (`new_message`, `advisor_requested`, etc.) |
|
||||
| `message` | TEXT | Cuerpo de la notificación |
|
||||
| `is_read` | TINYINT(1) | Si fue leída |
|
||||
| `created_at`| TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 7.10 `terms_versions` — Versiones de términos y condiciones
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|--------------------|---------------|-------------------------------------------------------|
|
||||
| `id` | INT | Clave primaria |
|
||||
| `version` | VARCHAR(20) | Versión (ej: `1.0`, `2.0`) |
|
||||
| `titulo` | VARCHAR(255) | Título del documento |
|
||||
| `documento_url` | TEXT | URL pública del PDF de términos |
|
||||
| `mensaje_aceptacion` | TEXT | Mensaje que se envía al usuario solicitando aceptación |
|
||||
| `activa` | TINYINT(1) | `1` = versión vigente |
|
||||
| `forzar_reenvio` | TINYINT(1) | `1` = forzar que todos los usuarios re-acepten |
|
||||
| `created_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 7.11 `terms_acceptance` — Aceptación de términos
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|--------------------|---------------|---------------------------------------|
|
||||
| `id` | INT | Clave primaria |
|
||||
| `user_id` | INT | FK → `users.id` |
|
||||
| `terms_version_id` | INT | FK → `terms_versions.id` |
|
||||
| `phone_number` | VARCHAR(20) | Teléfono del usuario |
|
||||
| `estado` | ENUM | `pendiente`, `aceptado`, `rechazado` |
|
||||
| `fecha_envio` | DATETIME | Cuándo se envió la solicitud |
|
||||
| `fecha_respuesta` | DATETIME NULL | Cuándo respondió el usuario |
|
||||
|
||||
---
|
||||
|
||||
### 7.12 `admin_users` — Administradores del sistema
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|----------------|---------------|----------------------------------|
|
||||
| `id` | INT | Clave primaria |
|
||||
| `username` | VARCHAR(50) | Nombre de usuario (UNIQUE) |
|
||||
| `password_hash`| VARCHAR(255) | Bcrypt hash de la contraseña |
|
||||
| `full_name` | VARCHAR(100) | Nombre completo |
|
||||
| `email` | VARCHAR(100) | Correo electrónico |
|
||||
| `is_active` | TINYINT(1) | `1` = activo |
|
||||
| `role_id` | INT NULL | FK → `roles.id` |
|
||||
| `last_login` | TIMESTAMP | Último login |
|
||||
|
||||
---
|
||||
|
||||
### 7.13 `webhook_logs` — Logs del webhook
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|---------------|---------------|---------------------------------------|
|
||||
| `id` | INT | Clave primaria |
|
||||
| `payload` | LONGTEXT | Body JSON recibido de WhatsApp |
|
||||
| `response` | TEXT | Respuesta devuelta |
|
||||
| `status_code` | INT | Código HTTP |
|
||||
| `created_at` | TIMESTAMP | Fecha del evento |
|
||||
|
||||
---
|
||||
|
||||
## 8. Formato del número de teléfono
|
||||
|
||||
Los números se almacenan y envían **sin el símbolo `+`**, en formato E.164:
|
||||
|
||||
- Colombia: `573001234567` (código país 57 + número sin prefijo)
|
||||
- Argentina: `5491123456789`
|
||||
|
||||
El sistema normaliza automáticamente:
|
||||
- Elimina caracteres no numéricos.
|
||||
- Si el número tiene 10 dígitos y empieza con `3` → agrega prefijo `57` (Colombia).
|
||||
- Si ya tiene 12 dígitos → lo usa tal cual.
|
||||
|
||||
---
|
||||
|
||||
## 9. Lógica de estados del usuario
|
||||
|
||||
El bot controla el flujo mediante campos en la tabla `users`:
|
||||
|
||||
```
|
||||
Estado normal:
|
||||
bot_enabled=1, in_service=0, on_hold=0, advisor_requested=0, bot_paused_until=NULL
|
||||
→ Bot responde normalmente
|
||||
|
||||
En menú:
|
||||
current_menu_id=<id> → Bot espera número de opción
|
||||
|
||||
Atendido por asesor:
|
||||
in_service=1, in_service_by=<admin_id>
|
||||
→ Bot NO responde, asesor humano escribe directamente
|
||||
|
||||
En espera (hold activo):
|
||||
on_hold=1, bot_paused_until=<datetime>
|
||||
→ Bot notifica que está en espera, solo permite MENU/ATRÁS/números
|
||||
|
||||
Asesor solicitado:
|
||||
advisor_requested=1, bot_paused_until=<datetime>
|
||||
→ Bot espera al asesor, solo permite navegación explícita
|
||||
|
||||
Bot pausado:
|
||||
bot_paused_until=<datetime futura>
|
||||
→ Bot no responde mensajes libres, solo menús/comandos
|
||||
|
||||
Pendiente de términos:
|
||||
terms_pending=1
|
||||
→ Bot solo procesa respuesta "acepto" o "rechazo"
|
||||
|
||||
Usuario bloqueado:
|
||||
status='blocked'
|
||||
→ Todos los mensajes se descartan silenciosamente
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Estados de conversación (tabla `user_states`)
|
||||
|
||||
Estados clave/valor persistentes por usuario (como sesión):
|
||||
|
||||
| `state_key` | Descripción |
|
||||
|----------------|---------------------------------------------------------------|
|
||||
| `menu_history` | Array JSON con historial de IDs de menús visitados |
|
||||
| `current_menu` | ID del menú actual |
|
||||
| `form_data` | Datos temporales de un formulario en curso |
|
||||
|
||||
---
|
||||
|
||||
## 11. Endpoints internos del sistema (API interna)
|
||||
|
||||
Estos endpoints son para el **panel de administración**, no son de WhatsApp. Se implementarían como API REST en el nuevo sistema:
|
||||
|
||||
| Método | Ruta interna | Descripción |
|
||||
|--------|-------------------------------|--------------------------------------------------|
|
||||
| GET | `/api/get_conversations.php` | Lista de conversaciones con paginación |
|
||||
| GET | `/api/get_conversation_detail.php?user_id=X` | Mensajes de un usuario |
|
||||
| POST | `/api/send_message.php` | Enviar mensaje desde el panel |
|
||||
| POST | `/api/send_reply.php` | Responder a un mensaje |
|
||||
| POST | `/api/attend.php` | Marcar usuario como "en atención" por asesor |
|
||||
| POST | `/api/finish_attend.php` | Liberar usuario de la atención |
|
||||
| POST | `/api/release_hold.php` | Liberar usuario del estado on_hold |
|
||||
| GET | `/api/get_templates.php` | Lista de plantillas aprobadas |
|
||||
| POST | `/api/send_broadcast.php` | Enviar mensaje masivo a múltiples usuarios |
|
||||
| POST | `/api/schedule_message.php` | Programar un mensaje |
|
||||
| GET | `/api/get_menus.php` | Lista de menús configurados |
|
||||
| POST | `/api/save_menu.php` | Crear/editar menú |
|
||||
| GET | `/api/get_settings.php` | Configuración del sistema |
|
||||
| POST | `/api/save_settings.php` | Guardar configuración |
|
||||
| GET | `/api/sse_events.php` | Server-Sent Events para tiempo real |
|
||||
|
||||
---
|
||||
|
||||
## 12. Flujo de términos y condiciones
|
||||
|
||||
```
|
||||
Usuario envía mensaje
|
||||
│
|
||||
▼
|
||||
¿needsTermsAcceptance()?
|
||||
│ SÍ
|
||||
▼
|
||||
sendTermsMessage()
|
||||
→ Envía texto con el mensaje de términos + URL del documento
|
||||
→ Guarda users.terms_pending = 1
|
||||
→ Inserta en terms_acceptance (estado: 'pendiente')
|
||||
│
|
||||
Usuario responde
|
||||
│
|
||||
processTermsResponse()
|
||||
├─ Si respuesta contiene "acepto", "si", "sí", "ok", "1" → ACEPTA
|
||||
│ → Actualiza users.terms_accepted_at, terms_version_id
|
||||
│ → Actualiza terms_acceptance.estado = 'aceptado'
|
||||
│ → Envía mensaje de confirmación
|
||||
└─ Si respuesta contiene "no", "rechazo", "2" → RECHAZA
|
||||
→ Actualiza terms_acceptance.estado = 'rechazado'
|
||||
→ Envía mensaje de rechazo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Worker de mensajes programados
|
||||
|
||||
Un proceso cron (o worker) ejecuta cada minuto:
|
||||
|
||||
1. Consulta `scheduled_messages` donde `status = 'pending'` y `scheduled_date + scheduled_time <= NOW()`.
|
||||
2. Para cada registro:
|
||||
- Si `message_type = 'template'` → llama a `WhatsAppService::sendTemplateMessage()`.
|
||||
- Si `message_type = 'text'` → llama a `WhatsAppService::sendTextMessage()`.
|
||||
3. Actualiza `status = 'sent'` y registra `sent_at`.
|
||||
4. Si falla → `status = 'failed'` + registra `error_message`.
|
||||
|
||||
---
|
||||
|
||||
## 14. Diagrama de clases del bot
|
||||
|
||||
```
|
||||
WhatsAppWebhook (api/webhook.php)
|
||||
│
|
||||
├──uses──▶ WhatsAppService (services/WhatsAppService.php)
|
||||
│ ├── sendTextMessage($to, $message)
|
||||
│ ├── sendTemplateMessage($to, $name, $lang, $bodyParams, $headerParams, $rawComponents)
|
||||
│ ├── sendInteractiveMessage($to, $body, $buttons, $header, $footer)
|
||||
│ ├── sendListMessage($to, $body, $buttonText, $sections, $header, $footer)
|
||||
│ ├── sendImageMessage($to, $url, $caption)
|
||||
│ ├── sendVideoMessage($to, $url, $caption)
|
||||
│ ├── sendAudioMessage($to, $url)
|
||||
│ ├── sendDocumentMessage($to, $url, $filename, $caption)
|
||||
│ ├── sendReactionMessage($to, $messageId, $emoji)
|
||||
│ ├── markAsRead($messageId)
|
||||
│ ├── uploadMedia($filePath, $mimeType) → retorna { id: "..." }
|
||||
│ └── getMediaUrl($mediaId) → retorna URL temporal
|
||||
│
|
||||
└──uses──▶ BotService (services/BotService.php)
|
||||
│
|
||||
├──uses──▶ MenuService (services/MenuService.php)
|
||||
│ └── getMenu($id), getMenuOptions($menuId)
|
||||
│
|
||||
├──uses──▶ ConversationStateService (services/ConversationStateService.php)
|
||||
│ ├── getCurrentMenuId($phone)
|
||||
│ ├── setCurrentMenu($phone, $menuId)
|
||||
│ └── getStateData($phone, $key)
|
||||
│
|
||||
├──uses──▶ BusinessHoursService (services/BusinessHoursService.php)
|
||||
│ └── isWithinBusinessHours()
|
||||
│
|
||||
└──uses──▶ NLPService (services/NLPService.php)
|
||||
└── analyze($text) → detección de intenciones básica
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. Checklist de implementación
|
||||
|
||||
Para reimplementar el bot en otro sistema/lenguaje:
|
||||
|
||||
- [ ] Configurar la App en Meta for Developers con permisos `whatsapp_business_messaging`, `whatsapp_business_management`.
|
||||
- [ ] Obtener: `Access Token`, `Phone Number ID`, `WABA ID`, `Webhook Verify Token`.
|
||||
- [ ] Crear endpoint público HTTPS para el webhook (GET para verificación, POST para mensajes).
|
||||
- [ ] Crear las tablas de base de datos: `users`, `conversations`, `menus`, `menu_options`, `message_templates`, `autoresponses`, `system_config`, `notifications`, `terms_versions`, `terms_acceptance`, `scheduled_messages`, `webhook_logs`.
|
||||
- [ ] Implementar `WhatsAppService` con los métodos de envío (ver sección 3).
|
||||
- [ ] Implementar la lógica de estados del usuario (sección 9).
|
||||
- [ ] Implementar el flujo de menús dinámicos (mensajes numerados).
|
||||
- [ ] Implementar el flujo de términos y condiciones (sección 12).
|
||||
- [ ] Implementar sistema de advisory lock por usuario para evitar mensajes duplicados en procesamiento concurrente.
|
||||
- [ ] Implementar cron/worker para mensajes programados (sección 13).
|
||||
- [ ] Implementar reinicio de sesión después de 6h de inactividad.
|
||||
|
||||
---
|
||||
|
||||
## 16. Notas de seguridad
|
||||
|
||||
- **Nunca** expongas el `whatsapp_token` en el frontend.
|
||||
- Validar siempre el `hub.verify_token` en el handshake del webhook.
|
||||
- Sanear todos los campos de texto antes de almacenar en BD para evitar XSS/SQLi.
|
||||
- Los mensajes duplicados se detectan por `message_id` único (`wamid.XXX`) — siempre validar antes de procesar.
|
||||
- Usar advisory locks a nivel de BD para evitar race conditions cuando WhatsApp envía el mismo webhook dos veces en paralelo.
|
||||
Reference in New Issue
Block a user