917 lines
39 KiB
Markdown
917 lines
39 KiB
Markdown
# 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.
|