From bc422e0a1cab3059f665d022c502d1639ced96d9 Mon Sep 17 00:00:00 2001
From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com>
Date: Fri, 12 Jun 2026 12:18:21 -0500
Subject: [PATCH] cambios importantes
---
.env.example | 16 +
.task-project | 3 +
API-ERP.md | 589 ++++++++++++
admin/DashboardController.php | 1505 ++++++++++++++++++++++++++++--
admin/v1/WpWebhook.php | 108 ++-
bot-whatsapp-palmas360.html | 1482 +++++++++++++++++++++++++++++
public/index.php | 254 +++++
services/AiBot.php | 155 +++
services/BotRouter.php | 173 ++++
services/CompanyApiClient.php | 70 ++
services/CompanyRepository.php | 82 ++
services/ConversationContext.php | 81 ++
services/ErpMonitor.php | 120 +++
services/ErpSync.php | 86 ++
services/NormalBot.php | 240 +++++
services/OutboundWorker.php | 96 ++
services/PendingApproval.php | 113 +++
services/Settings.php | 40 +
services/WhatsAppSender.php | 106 +++
setup/migrate.php | 128 +++
20 files changed, 5348 insertions(+), 99 deletions(-)
create mode 100644 .task-project
create mode 100644 API-ERP.md
create mode 100644 bot-whatsapp-palmas360.html
create mode 100644 services/AiBot.php
create mode 100644 services/BotRouter.php
create mode 100644 services/CompanyApiClient.php
create mode 100644 services/CompanyRepository.php
create mode 100644 services/ConversationContext.php
create mode 100644 services/ErpMonitor.php
create mode 100644 services/ErpSync.php
create mode 100644 services/NormalBot.php
create mode 100644 services/OutboundWorker.php
create mode 100644 services/PendingApproval.php
create mode 100644 services/Settings.php
create mode 100644 services/WhatsAppSender.php
diff --git a/.env.example b/.env.example
index 9385fe8..8cc4f34 100644
--- a/.env.example
+++ b/.env.example
@@ -22,3 +22,19 @@ WHATSAPP_PHONE_NUMBER_ID=1234567890
# ID de la cuenta de WhatsApp Business
WHATSAPP_BUSINESS_ACCOUNT_ID=0987654321
+
+# ─── ERP / Multi-tenant ─────────────────────────────────────────────────────
+# URL del endpoint maestro en Palmas360 ERP que devuelve la lista de empresas
+# con sus números de WhatsApp y endpoints API individuales
+ERP_SYNC_API_URL=https://erp.palmas360.com/api/companies
+ERP_SYNC_API_KEY=token_para_sincronizar_empresas
+
+# ─── IA (Inteligencia Artificial) ────────────────────────────────────────────
+# Proveedor de IA: openai | mock
+AI_PROVIDER=mock
+# API Key de OpenAI (requerido si AI_PROVIDER=openai)
+OPENAI_API_KEY=
+# Modelo de OpenAI a usar
+OPENAI_MODEL=gpt-4o-mini
+# Máximo de tokens por respuesta
+AI_MAX_TOKENS=500
diff --git a/.task-project b/.task-project
new file mode 100644
index 0000000..ddc02be
--- /dev/null
+++ b/.task-project
@@ -0,0 +1,3 @@
+{
+ "project": "/Users/lizandro/Documents/USITE/PROYECTOS/bot_palmas"
+}
\ No newline at end of file
diff --git a/API-ERP.md b/API-ERP.md
new file mode 100644
index 0000000..286f7c3
--- /dev/null
+++ b/API-ERP.md
@@ -0,0 +1,589 @@
+# Documentación API — Integración ERP ↔ bot-palmas360
+
+## Índice
+
+1. [Autenticación](#1-autenticación)
+2. [Endpoints que el ERP debe exponer](#2-endpoints-que-el-erp-debe-exponer)
+3. [Endpoints que el ERP debe consumir](#3-endpoints-que-el-erp-debe-consumir)
+4. [Flujo completo](#4-flujo-completo)
+5. [Implementación de referencia en PHP](#5-implementación-de-referencia-en-php)
+
+---
+
+## 1. Autenticación
+
+Todas las llamadas entre el bot y el ERP usan **API Key** via header:
+
+```
+X-API-Key: tu_api_key_secreta
+```
+
+Cada empresa tiene su propia `api_key` configurada en la tabla `companies`. Esta misma key la usa el bot para autenticarse cuando llama al ERP, y el ERP la usa para autenticarse cuando llama al bot.
+
+---
+
+## 2. Endpoints que el ERP debe exponer
+
+### 2.1 Lista de empresas (sincronización)
+
+Usado por el bot para obtener la lista completa de empresas con sus números y URLs.
+
+**`GET {ERP_SYNC_API_URL}`**
+
+Autenticación: `Authorization: Bearer {ERP_SYNC_API_KEY}` (configurado en `.env`)
+
+**Respuesta exitosa (200):**
+```json
+[
+ {
+ "name": "Empresa Uno",
+ "display_name": "Empresa Uno S.A.S.",
+ "phone_number_id": "384710295638401",
+ "display_phone": "573001234567",
+ "api_base_url": "https://erp.empresauno.com/api",
+ "api_key": "key_secreta_empresa_1",
+ "bot_type": "hybrid",
+ "requires_approval": true,
+ "is_active": true,
+ "config_json": {
+ "commands": {
+ "menu": "show_main",
+ "info": "show_info",
+ "contacto": "show_contact",
+ "horario": "show_hours"
+ },
+ "menus": {
+ "show_main": {
+ "type": "list",
+ "header": "Bienvenido a Empresa Uno",
+ "body": "Selecciona una opción:",
+ "footer": "Empresa Uno",
+ "button": "Ver opciones",
+ "sections": [
+ {
+ "title": "Información",
+ "rows": [
+ { "id": "servicios", "title": "Servicios", "description": "Nuestros servicios disponibles" },
+ { "id": "contacto", "title": "Contacto", "description": "Habla con un asesor" }
+ ]
+ }
+ ]
+ }
+ },
+ "flows": {
+ "servicios": { "type": "text", "message": "Ofrecemos servicios de consultoría y desarrollo." },
+ "contacto": { "type": "function", "function": "forward_to_ai" }
+ },
+ "ai_prompt": "Eres el asistente virtual de Empresa Uno. Responde preguntas sobre servicios.",
+ "fallback": "No entendí. Escribe 'menu' para ver opciones.",
+ "approval_webhook": "https://erp.empresauno.com/api/approval-notify"
+ }
+ }
+]
+```
+
+> **Nota:** El bot almacena estos datos localmente en la tabla `companies`. El campo `config_json` define el comportamiento del bot normal, mensajes de IA, menús interactivos y webhooks de notificación.
+
+---
+
+### 2.2 Recepción de mensajes entrantes
+
+El bot envía aquí cada mensaje que llega por WhatsApp.
+
+**`POST {empresa.api_base_url}/webhook/incoming`**
+
+Autenticación: `X-API-Key: {empresa.api_key}`
+
+**Payload:**
+```json
+{
+ "company_id": 1,
+ "from": "573001234567",
+ "name": "Juan Pérez",
+ "message_id": "wamid.HBgNNTczMDAxMjM0NTY3FQIAERgSN0QzQjFGQ0YyRjU3OTJGMwA=",
+ "type": "text",
+ "content": "Hola, quiero información sobre los precios",
+ "media_id": null,
+ "timestamp": 1717600000,
+ "phone_number_id": "384710295638401",
+ "display_phone": "573001234567",
+ "raw_payload": "{...payload completo de Meta...}"
+}
+```
+
+**Respuesta esperada (200):**
+```json
+{
+ "status": "received"
+}
+```
+
+> **Importante:** El bot responderá 200 a Meta inmediatamente después de recibir el mensaje. La respuesta al usuario la maneja el bot según la configuración de la empresa (respuesta automática, IA, o cola de aprobación).
+
+---
+
+### 2.3 Estados de mensajes
+
+El bot notifica aquí cuando cambia el estado de un mensaje enviado.
+
+**`POST {empresa.api_base_url}/webhook/status`**
+
+Autenticación: `X-API-Key: {empresa.api_key}`
+
+**Payload:**
+```json
+{
+ "company_id": 1,
+ "message_id": "wamid.HBgNNTczMDAxMjM0NTY3FQIAERgSN0QzQjFGQ0YyRjU3OTJGMwA=",
+ "status": "sent",
+ "recipient": "573001234567",
+ "timestamp": 1717600000,
+ "errors": null
+}
+```
+
+Posibles valores de `status`: `sent`, `delivered`, `read`, `failed`
+
+Cuando es `failed`, el campo `errors` contiene detalles del error:
+```json
+{
+ "status": "failed",
+ "errors": [
+ {
+ "code": 131026,
+ "title": "Message undeliverable",
+ "details": "Phone number has unsubscribed from the channel"
+ }
+ ]
+}
+```
+
+**Respuesta esperada (200):**
+```json
+{
+ "status": "received"
+}
+```
+
+---
+
+### 2.4 Health check (opcional pero recomendado)
+
+Usado por el monitor de salud del bot para verificar disponibilidad.
+
+**`GET {empresa.api_base_url}/health`**
+
+Autenticación: `X-API-Key: {empresa.api_key}` (opcional en este endpoint)
+
+No requiere payload. El bot solo verifica que responda HTTP 200.
+
+**Respuesta esperada (200):**
+```json
+{
+ "status": "ok",
+ "service": "erp-empresa-uno",
+ "timestamp": "2026-06-12T10:00:00Z"
+}
+```
+
+---
+
+## 3. Endpoints que el ERP debe consumir
+
+### 3.1 Enviar mensaje directo
+
+**`POST https://bot.palmas360.com/api/send-direct`**
+
+Autenticación: `X-API-Key: {api_key de tu empresa}`
+
+Usa este endpoint cuando quieras enviar un mensaje a un cliente desde el ERP (ej: un asesor responde manualmente desde tu dashboard).
+
+**Payload (texto):**
+```json
+{
+ "to": "573001234567",
+ "type": "text",
+ "text": "Hola Juan, gracias por escribirnos. Te comparto nuestros precios: Plan Básico $49/mes, Plan Premium $99/mes."
+}
+```
+
+**Payload (imagen):**
+```json
+{
+ "to": "573001234567",
+ "type": "image",
+ "media_id": "123456789",
+ "url": "https://tuservidor.com/imagen.jpg",
+ "caption": "Nuestro catálogo de productos"
+}
+```
+
+> Envia `media_id` (ID de Meta) o `url` (URL pública). Si envías ambos, prioriza `media_id`.
+
+**Payload (template):**
+```json
+{
+ "to": "573001234567",
+ "type": "template",
+ "template_name": "bienvenida",
+ "components": [
+ {
+ "type": "body",
+ "parameters": [
+ { "type": "text", "text": "Juan" }
+ ]
+ }
+ ]
+}
+```
+
+**Respuesta exitosa (200):**
+```json
+{
+ "status": "queued",
+ "id": 123
+}
+```
+
+El mensaje se encola y se envía en segundo plano. El estado de envío llegará a tu `webhook/status`.
+
+---
+
+### 3.2 Ver mensajes pendientes de aprobación
+
+**`GET https://bot.palmas360.com/api/pending`**
+
+Autenticación: `X-API-Key: {api_key de tu empresa}`
+
+Devuelve todos los mensajes que el bot procesó pero que requieren aprobación humana antes de enviarse.
+
+**Respuesta exitosa (200):**
+```json
+{
+ "pending": [
+ {
+ "id": 5,
+ "company_id": 1,
+ "phone_number": "573001234567",
+ "contact_name": "Juan Pérez",
+ "incoming_msg": "Quiero saber el precio del servicio premium",
+ "bot_response": {
+ "action": "send",
+ "type": "text",
+ "to": "573001234567",
+ "payload": "{\"text\":\"El plan Premium cuesta $99/mes. ¿Te gustaría suscribirte?\"}"
+ },
+ "bot_type": "ai",
+ "status": "pending",
+ "created_at": "2026-06-12 09:30:00"
+ }
+ ],
+ "total": 1
+}
+```
+
+---
+
+### 3.3 Aprobar o rechazar mensaje
+
+**`POST https://bot.palmas360.com/api/approval`**
+
+Autenticación: `X-API-Key: {api_key de tu empresa}`
+
+Aprueba o rechaza un mensaje pendiente.
+
+**Payload (aprobar):**
+```json
+{
+ "pending_id": 5,
+ "action": "approve",
+ "note": "Aprobado por gerencia"
+}
+```
+
+**Respuesta (200):**
+```json
+{
+ "status": "approved",
+ "pending_id": 5,
+ "message": "Respuesta aprobada y encolada para envío"
+}
+```
+
+**Payload (rechazar):**
+```json
+{
+ "pending_id": 5,
+ "action": "reject",
+ "note": "El cliente pidió información incorrecta"
+}
+```
+
+**Respuesta (200):**
+```json
+{
+ "status": "rejected",
+ "pending_id": 5,
+ "message": "Mensaje rechazado"
+}
+```
+
+> Cuando apruebas, el bot automáticamente encola la respuesta generada para enviarla al cliente por WhatsApp.
+
+---
+
+### 3.4 Listar empresas (opcional)
+
+Si necesitas consultar qué empresas están registradas en el bot:
+
+**`GET https://bot.palmas360.com/api/companies`**
+
+Autenticación: `X-API-Key: {api_key de tu empresa}` (solo empresas activas)
+
+**Respuesta (200):**
+```json
+{
+ "companies": [
+ {
+ "id": 1,
+ "name": "Empresa Uno",
+ "display_name": "Empresa Uno S.A.S.",
+ "phone_number_id": "384710295638401",
+ "display_phone": "573001234567",
+ "bot_type": "hybrid",
+ "requires_approval": true,
+ "is_active": true
+ }
+ ]
+}
+```
+
+---
+
+## 4. Flujo completo
+
+```
+Cliente WhatsApp bot-palmas360 ERP Palmas360
+ │ │ │
+ │ Mensaje de texto │ │
+ │ ─────────────────────────────────► │ │
+ │ │ │
+ │ │ POST /webhook/incoming │
+ │ │ ──────────────────────────────► │
+ │ │ ◄─── { status: "received" } ─── │
+ │ │ │
+ │ │ BotRouter decide: │
+ │ │ ├─ Respuesta directa │
+ │ │ │ → encola en outbound_queue │
+ │ │ │ → WhatsAppSender envía │
+ │ │ │ → POST /webhook/status │
+ │ │ │ ──────────────────────────► │
+ │ │ │ │
+ │ ◄─── Respuesta IA/Bot ────────── │ │ │
+ │ │ │ │
+ │ │ └─ requires_approval = true │
+ │ │ → guarda en pending_approval │
+ │ │ → notifica vía webhook (opc) │
+ │ │ │
+ │ │ GET /api/pending │
+ │ │ ◄──────────────────────────── │
+ │ │ ── lista pendientes ──────► │
+ │ │ │
+ │ │ POST /api/approval │
+ │ │ ◄──────────────────────────── │
+ │ │ ── approve/reject ─────────► │
+ │ │ │
+ │ │ (si approve) │
+ │ ◄─── Respuesta aprobada ──────── │ → outbound_queue │
+ │ │ → WhatsAppSender envía │
+ │ │ │
+ │ │ ERP inicia conversación │
+ │ │ POST /api/send-direct │
+ │ │ ◄──────────────────────────── │
+ │ ◄─── Mensaje del asesor ──────── │ ── mensaje outbound ────────► │
+ │ │ │
+```
+
+---
+
+## 5. Implementación de referencia en PHP
+
+```php
+baseUrl = rtrim($baseUrl, '/');
+ $this->apiKey = $apiKey;
+ }
+
+ /**
+ * Enviar un mensaje de texto
+ */
+ public function sendText(string $to, string $text): array
+ {
+ return $this->post('/api/send-direct', [
+ 'to' => $to,
+ 'type' => 'text',
+ 'text' => $text,
+ ]);
+ }
+
+ /**
+ * Enviar una imagen
+ */
+ public function sendImage(string $to, string $url, ?string $caption = null): array
+ {
+ $payload = [
+ 'to' => $to,
+ 'type' => 'image',
+ 'url' => $url,
+ ];
+ if ($caption !== null) {
+ $payload['caption'] = $caption;
+ }
+ return $this->post('/api/send-direct', $payload);
+ }
+
+ /**
+ * Enviar una plantilla (template)
+ */
+ public function sendTemplate(string $to, string $templateName, array $components = []): array
+ {
+ return $this->post('/api/send-direct', [
+ 'to' => $to,
+ 'type' => 'template',
+ 'template_name' => $templateName,
+ 'components' => $components,
+ ]);
+ }
+
+ /**
+ * Obtener mensajes pendientes de aprobación
+ */
+ public function getPending(): array
+ {
+ return $this->get('/api/pending');
+ }
+
+ /**
+ * Aprobar un mensaje pendiente
+ */
+ public function approvePending(int $pendingId, ?string $note = null): array
+ {
+ return $this->post('/api/approval', [
+ 'pending_id' => $pendingId,
+ 'action' => 'approve',
+ 'note' => $note,
+ ]);
+ }
+
+ /**
+ * Rechazar un mensaje pendiente
+ */
+ public function rejectPending(int $pendingId, ?string $note = null): array
+ {
+ return $this->post('/api/approval', [
+ 'pending_id' => $pendingId,
+ 'action' => 'reject',
+ 'note' => $note,
+ ]);
+ }
+
+ // ─── Helpers HTTP ───────────────────────────────────────────────────────
+
+ private function get(string $path): array
+ {
+ return $this->request('GET', $path);
+ }
+
+ private function post(string $path, array $data): array
+ {
+ return $this->request('POST', $path, $data);
+ }
+
+ private function request(string $method, string $path, ?array $data = null): array
+ {
+ $url = $this->baseUrl . $path;
+
+ $ch = curl_init($url);
+ $headers = [
+ 'X-API-Key: ' . $this->apiKey,
+ 'Content-Type: application/json',
+ 'Accept: application/json',
+ ];
+
+ $options = [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_TIMEOUT => 15,
+ ];
+
+ if ($method === 'POST') {
+ $options[CURLOPT_POST] = true;
+ $options[CURLOPT_POSTFIELDS] = json_encode($data ?? []);
+ }
+
+ curl_setopt_array($ch, $options);
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $error = curl_error($ch);
+ curl_close($ch);
+
+ if ($error !== '') {
+ return ['success' => false, 'error' => $error];
+ }
+
+ $decoded = json_decode($response, true);
+
+ return [
+ 'success' => $httpCode >= 200 && $httpCode < 300,
+ 'http_code' => $httpCode,
+ 'data' => $decoded ?? $response,
+ ];
+ }
+}
+
+// ─── Ejemplo de uso ────────────────────────────────────────────────────────
+
+$bot = new BotPalmasClient('https://bot.palmas360.com', 'api_key_de_mi_empresa');
+
+// Enviar mensaje
+$result = $bot->sendText('573001234567', 'Hola, gracias por escribirnos');
+print_r($result);
+
+// Ver pendientes
+$pending = $bot->getPending();
+foreach ($pending['pending'] ?? [] as $item) {
+ echo "Pendiente #{$item['id']}: {$item['incoming_msg']}\n";
+}
+
+// Aprobar el primero
+if (!empty($pending['pending'])) {
+ $bot->approvePending((int)$pending['pending'][0]['id'], 'Aprobado desde ERP');
+}
+```
+
+---
+
+## Resumen rápido de endpoints
+
+| Dirección | Qué hace |
+|---|---|
+| **ERP → Bot** | |
+| `POST /api/send-direct` | Enviar mensaje WhatsApp |
+| `GET /api/pending` | Ver pendientes de aprobación |
+| `POST /api/approval` | Aprobar/rechazar pendiente |
+| **Bot → ERP** | |
+| `POST /webhook/incoming` | Recibir mensaje entrante |
+| `POST /webhook/status` | Recibir estado de envío |
+| `GET /health` | Health check |
diff --git a/admin/DashboardController.php b/admin/DashboardController.php
index bd1b8db..493affb 100644
--- a/admin/DashboardController.php
+++ b/admin/DashboardController.php
@@ -11,28 +11,34 @@ class DashboardController
{
SessionAuth::require();
- $page = max(1, (int)($_GET['page'] ?? 1));
- $filter = trim($_GET['type'] ?? '');
- $search = trim($_GET['search'] ?? '');
- $date = trim($_GET['date'] ?? date('Y-m-d'));
- $user = SessionAuth::user();
+ $page = max(1, (int)($_GET['page'] ?? 1));
+ $filter = trim($_GET['type'] ?? '');
+ $search = trim($_GET['search'] ?? '');
+ $date = trim($_GET['date'] ?? date('Y-m-d'));
+ $companyId = isset($_GET['company_id']) ? (int)$_GET['company_id'] : null;
+ $user = SessionAuth::user();
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d');
}
try {
- $db = db();
- $stats = self::getStats($db);
- [$logs, $total] = self::getLogs($db, $page, $filter, $search, $date);
+ $db = db();
+ $stats = self::getStats($db, $companyId);
+ $pending = PendingApproval::countPending($companyId);
+ $companies = CompanyRepository::findAll();
+ [$logs, $total] = self::getLogs($db, $page, $filter, $search, $date, $companyId);
} catch (\PDOException $e) {
- $stats = ['total' => 0, 'msgs' => 0, 'media' => 0, 'statuses' => 0];
- $logs = [];
- $total = 0;
+ $stats = ['total' => 0, 'msgs' => 0, 'media' => 0, 'statuses' => 0];
+ $pending = 0;
+ $companies = [];
+ $logs = [];
+ $total = 0;
}
$pages = $total > 0 ? (int)ceil($total / self::PER_PAGE) : 1;
- self::render(compact('stats', 'logs', 'total', 'page', 'pages', 'filter', 'search', 'date', 'user'));
+ $companyCount = count($companies);
+ self::render(compact('stats', 'pending', 'companies', 'companyCount', 'companyId', 'logs', 'total', 'page', 'pages', 'filter', 'search', 'date', 'user'));
}
// ─── GET /admin/live ────────────────────────────────────────────────────
@@ -54,13 +60,17 @@ class DashboardController
Live — Palmas360
-
Palmas360 · Admin
+
Palmas360
Somos19D
@@ -502,11 +580,28 @@ HTML;
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes ({$sPending})
+ 🏢 Empresas
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+ ⚙️ Configuración
+
+
{$sMsgs}
💬 Mensajes texto
+
{$sPending}
⏳ Pendientes aprobación
+
{$companyCount}
🏢 Empresas
+
+
+
+ 📡 Estado ERP
+ Cargando...
@@ -517,6 +612,7 @@ HTML;
Mensajes
Estados
+ {$companyOptions}
🔍 Filtrar
{$totalStr} resultado(s)
@@ -524,11 +620,12 @@ HTML;
-
+
ID
Hora
+ {$companyTh}
Número
Nombre
Tipo
@@ -576,10 +673,29 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal()
const subtitleStyle = 'color: #90caf9; font-size: 12px;';
const warningStyle = 'color: #ffb300; font-size: 12px;';
- console.log('%cDesarrollado por U-site', titleStyle);
- console.log('%cAlgo cool para tu consola: esto es un panel seguro y sólo para gestión.', subtitleStyle);
- console.log('%cNo deberías pegar scripts en esta parte del navegador, amigo. Mantén la consola limpia y segura.', warningStyle);
-})();
+ console.log('%cDesarrollado por U-site', titleStyle);
+ console.log('%cAlgo cool para tu consola: esto es un panel seguro y sólo para gestión.', subtitleStyle);
+ console.log('%cNo deberías pegar scripts en esta parte del navegador, amigo. Mantén la consola limpia y segura.', warningStyle);
+ })();
+
+ // ─── ERP Health ─────────────────────────────────────────────────────────────
+ (async function loadErpHealth() {
+ try {
+ const r = await fetch('/admin/erp-health');
+ if (!r.ok) { document.getElementById('erpLoad').textContent = 'Error al cargar'; return; }
+ const data = await r.json();
+ const bar = document.getElementById('erpBar');
+ let html = '';
+ (data.results || []).forEach(erp => {
+ const st = erp.status || 'unknown';
+ const ms = erp.latency_ms != null ? erp.latency_ms + 'ms' : '—';
+ html += ' ' + esc(erp.company_name) + ' (' + ms + ') ';
+ });
+ document.getElementById('erpLoad').innerHTML = html || 'Sin empresas configuradas';
+ } catch(e) {
+ document.getElementById('erpLoad').textContent = 'Error de conexión';
+ }
+ })();
@@ -587,4 +703,1215 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal()
HTML;
exit;
}
+
+ // ─── GET /admin/pending ────────────────────────────────────────────────
+
+ public static function pending(): void
+ {
+ SessionAuth::require();
+ $user = SessionAuth::user();
+ $userName = htmlspecialchars($user['name'] ?? 'Admin', ENT_QUOTES, 'UTF-8');
+ $companyId = isset($_GET['company_id']) ? (int)$_GET['company_id'] : null;
+ $items = $companyId ? PendingApproval::findByCompany($companyId, 'pending') : PendingApproval::findAll('pending');
+ $companies = CompanyRepository::findAll();
+
+ http_response_code(200);
+ header('Content-Type: text/html; charset=utf-8');
+ echo <<
+
+
+
+
+ Pendientes — Palmas360
+
+
+
+
+
+
Pendientes de Aprobación
+
+
+
👤 {$userName}
+
Salir
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+ ⚙️ Configuración
+
+
+
';
+
+ if (empty($items)) {
+ echo '
✅ No hay mensajes pendientes de aprobación.
';
+ } else {
+ echo '
+ ID Empresa De Mensaje recibido Respuesta Estado Acción
+ ';
+ foreach ($items as $item) {
+ $id = (int)$item['id'];
+ $company = htmlspecialchars($item['company_name'] ?? '-', ENT_QUOTES, 'UTF-8');
+ $from = htmlspecialchars($item['from_phone'] ?? '-', ENT_QUOTES, 'UTF-8');
+ $inMsg = htmlspecialchars(mb_substr($item['incoming_message'] ?? '', 0, 60), ENT_QUOTES, 'UTF-8');
+ $reply = htmlspecialchars(mb_substr($item['reply_body'] ?? '', 0, 60), ENT_QUOTES, 'UTF-8');
+ $status = $item['status'] ?? 'pending';
+ echo "
+ {$id}
+ {$company}
+ {$from}
+ {$inMsg}
+ {$reply}
+ {$status}
+
+ ✓ Aprobar
+ ✗ Rechazar
+
+ ";
+ }
+ echo '
';
+ }
+ echo <<
+
+
+
+HTML;
+ exit;
+ }
+
+ // ─── GET /admin/companies ──────────────────────────────────────────────
+
+ public static function companies(): void
+ {
+ SessionAuth::require();
+ $user = SessionAuth::user();
+ $userName = self::h($user['name'] ?? 'Admin');
+ $companies = CompanyRepository::findAll(true);
+ $companyCount = count($companies);
+
+ $msg = $_GET['msg'] ?? '';
+ $toastHtml = '';
+ if ($msg !== '') {
+ $map = [
+ 'created' => 'Empresa creada exitosamente.',
+ 'updated' => 'Empresa actualizada exitosamente.',
+ 'deleted' => 'Empresa eliminada.',
+ 'error' => 'Ocurrió un error. Intente de nuevo.',
+ ];
+ $text = self::h($map[$msg] ?? '');
+ $cls = in_array($msg, ['created', 'updated']) ? 'toast-success' : 'toast-error';
+ if ($text !== '') {
+ $toastHtml = '
' . $text . '
';
+ }
+ }
+
+ http_response_code(200);
+ header('Content-Type: text/html; charset=utf-8');
+ echo <<
+
+
+
+
+
Empresas — Palmas360
+
+
+
+
+
+
Empresas
+
+
+
👤 {$userName}
+
Salir
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+ ⚙️ Configuración
+
+
+ {$toastHtml}
+
+
+HTML;
+ if (empty($companies)) {
+ echo '
';
+ } else {
+ echo '
+ ID Nombre WhatsApp Phone ID Teléfono Bot Type Aprueba Activo API URL Acciones
+ ';
+ foreach ($companies as $c) {
+ $id = (int)$c['id'];
+ $name = self::h($c['name'] ?? '');
+ $dname = self::h($c['display_name'] ?? '');
+ $pid = self::h((string)($c['phone_number_id'] ?? ''));
+ $phone = self::h($c['display_phone'] ?? '');
+ $bot = $c['bot_type'] ?? 'normal';
+ $apr = !empty($c['requires_approval']);
+ $act = !empty($c['is_active']);
+ $url = self::h($c['api_base_url'] ?? '');
+ $btag = match($bot) { 'hybrid' => 'hybrid', 'ai' => 'ai', default => 'normal' };
+ $eName = self::h($c['name'] ?? '');
+ echo "
+ {$id}
+ {$name} {$dname}
+ {$pid}
+ {$phone}
+ {$bot}
+ ' . ($apr ? 'Sí' : 'No') . "
+ ' . ($act ? 'Sí' : 'No') . "
+ {$url}
+
+ ✎ Editar
+ 🗑 Eliminar
+
+ ";
+ }
+ echo '
';
+ }
+ echo <<
+
Total: {$companyCount} empresa(s)
+
+
+
+HTML;
+ exit;
+ }
+
+ // ─── GET /admin/company/edit ─────────────────────────────────────────────
+
+ public static function companyEdit(): void
+ {
+ SessionAuth::require();
+ $user = SessionAuth::user();
+ $userName = self::h($user['name'] ?? 'Admin');
+ $id = (int)($_GET['id'] ?? 0);
+ $isEdit = $id > 0;
+ $company = $isEdit ? CompanyRepository::findById($id) : null;
+
+ $cv = fn(string $k, string $d = '') => self::h($company[$k] ?? $d);
+ $name = $cv('name');
+ $display_name = $cv('display_name');
+ $phone_number_id = $cv('phone_number_id');
+ $display_phone = $cv('display_phone');
+ $api_base_url = $cv('api_base_url');
+ $api_key = $cv('api_key');
+ $config_json = $cv('config_json');
+ $bot_type = $company['bot_type'] ?? 'normal';
+ $requires_approval = !empty($company['requires_approval']);
+ $is_active = !empty($company['is_active']);
+ $reqAprChecked = $requires_approval ? 'checked' : '';
+ $isActChecked = $is_active ? 'checked' : '';
+
+ $botOptions = '';
+ foreach (['normal' => 'Normal', 'ai' => 'AI', 'hybrid' => 'Híbrido'] as $val => $label) {
+ $sel = $bot_type === $val ? 'selected' : '';
+ $botOptions .= "
{$label} \n";
+ }
+
+ $pageTitle = $isEdit ? 'Editar Empresa' : 'Nueva Empresa';
+
+ http_response_code(200);
+ header('Content-Type: text/html; charset=utf-8');
+ echo <<
+
+
+
+
+
{$pageTitle} — Palmas360
+
+
+
+
+
+
{$pageTitle}
+
+
+
👤 {$userName}
+
Salir
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+ ⚙️ Configuración
+
+
+
+
+
+HTML;
+ exit;
+ }
+
+ // ─── GET /admin/sync-companies ──────────────────────────────────────────
+
+ public static function syncCompanies(): void
+ {
+ SessionAuth::require();
+ $user = SessionAuth::user();
+ $userName = self::h($user['name'] ?? 'Admin');
+ $result = ErpSync::sync();
+ $hasError = isset($result['error']);
+ $icon = $hasError ? '❌' : '✅';
+ $title = $hasError ? 'Error en Sincronización' : 'Sincronización Exitosa';
+ $titleClass = $hasError ? 'error' : 'success';
+ $resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+
+ http_response_code(200);
+ header('Content-Type: text/html; charset=utf-8');
+ echo <<
+
+
+
+
+
Sincronizar — Palmas360
+
+
+
+
+
+
Sincronizar Empresas
+
+
+
👤 {$userName}
+
Salir
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+ ⚙️ Configuración
+
+
+
+
{$icon}
+
{$title}
+
{$resultJson}
+
+
+
+
+
+HTML;
+ exit;
+ }
+
+ // ─── GET /admin/process-queue ───────────────────────────────────────────
+
+ public static function processQueue(): void
+ {
+ SessionAuth::require();
+ $user = SessionAuth::user();
+ $userName = self::h($user['name'] ?? 'Admin');
+ $result = OutboundWorker::processQueue();
+ $hasError = !empty($result['errors']);
+ $icon = $hasError ? '⚠' : '✅';
+ $title = $hasError ? 'Cola Procesada con Advertencias' : 'Cola Procesada Exitosamente';
+ $titleClass = $hasError ? 'error' : 'success';
+ $resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+
+ http_response_code(200);
+ header('Content-Type: text/html; charset=utf-8');
+ echo <<
+
+
+
+
+
Procesar Cola — Palmas360
+
+
+
+
+
+
Procesar Cola de Mensajes
+
+
+
👤 {$userName}
+
Salir
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+ ⚙️ Configuración
+
+
+
+
{$icon}
+
{$title}
+
{$resultJson}
+
+
+
+
+
+HTML;
+ exit;
+ }
+
+ // ─── GET /admin/settings ────────────────────────────────────────────────
+
+ public static function settings(): void
+ {
+ SessionAuth::require();
+ $user = SessionAuth::user();
+ $userName = self::h($user['name'] ?? 'Admin');
+ $settings = Settings::all();
+ $msg = $_GET['msg'] ?? '';
+ $toastHtml = '';
+ if ($msg === 'saved') {
+ $toastHtml = '
Configuración guardada exitosamente.
';
+ }
+
+ $fields = [
+ 'WhatsApp Cloud API' => [
+ ['key' => 'whatsapp_access_token', 'label' => 'Access Token', 'type' => 'password', 'placeholder' => 'EAAMzB...'],
+ ['key' => 'whatsapp_app_secret', 'label' => 'App Secret', 'type' => 'password', 'placeholder' => 'a4f82c1d...'],
+ ['key' => 'whatsapp_verify_token', 'label' => 'Verify Token', 'type' => 'text', 'placeholder' => 'PLM360_WH_...'],
+ ['key' => 'whatsapp_business_account_id', 'label' => 'Business Account ID', 'type' => 'text', 'placeholder' => '920641783052817'],
+ ['key' => 'whatsapp_default_phone_number_id', 'label' => 'Phone Number ID (default)', 'type' => 'text', 'placeholder' => '384710295638401'],
+ ],
+ 'Inteligencia Artificial' => [
+ ['key' => 'ai_provider', 'label' => 'Proveedor', 'type' => 'select', 'options' => ['mock' => 'Mock (simulado)', 'openai' => 'OpenAI']],
+ ['key' => 'openai_api_key', 'label' => 'OpenAI API Key', 'type' => 'password', 'placeholder' => 'sk-...'],
+ ['key' => 'openai_model', 'label' => 'Modelo', 'type' => 'select', 'options' => ['gpt-4o-mini' => 'GPT-4o Mini', 'gpt-4o' => 'GPT-4o', 'gpt-3.5-turbo' => 'GPT-3.5 Turbo']],
+ ['key' => 'ai_max_tokens', 'label' => 'Máximo de tokens', 'type' => 'number', 'placeholder' => '500'],
+ ['key' => 'ai_default_prompt', 'label' => 'System Prompt por defecto', 'type' => 'textarea', 'placeholder' => 'Eres un asistente...'],
+ ],
+ ];
+
+ $versionHash = substr(sha1_file(__DIR__ . '/../.env'), 0, 8);
+
+ http_response_code(200);
+ header('Content-Type: text/html; charset=utf-8');
+ echo <<
+
+
+
+
+
Configuración — Palmas360
+
+
+
+
+
+
Configuración
+
+
+
👤 {$userName}
+
Salir
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ ⚙️ Configuración
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+ ⚙️ Configuración
+
+
+
+
Los cambios se aplican inmediatamente. Los valores se almacenan en la base de datos.
+
+
+
+HTML;
+ exit;
+ }
+
+ // ─── Chat ────────────────────────────────────────────────────────────────
+
+ public static function chat(): void
+ {
+ SessionAuth::require();
+ $user = SessionAuth::user();
+ $userName = self::h($user['name'] ?? 'Admin');
+ $companies = CompanyRepository::findAll();
+
+ http_response_code(200);
+ header('Content-Type: text/html; charset=utf-8');
+ echo <<
+
+
+
+
+ Chat — Palmas360
+
+
+
+
+
+
Chat
+
+
+
👤 {$userName}
+
Salir
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ ⚙️ Configuración
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+
+
+
+
+
+
+
+
💬
+
Selecciona una conversación
+
+
+
+ ➤
+
+
+
+
+
+
+HTML;
+ exit;
+ }
+
+ // ─── API: lista de conversaciones ────────────────────────────────────────
+
+ public static function chatConversations(): void
+ {
+ SessionAuth::require();
+ $db = db();
+ // Get unique conversations with last message and unread count
+ $rows = $db->query("
+ SELECT c.phone_number, c.contact_name, c.company_id,
+ MAX(c.created_at) as last_time,
+ (SELECT content FROM conversations c2 WHERE c2.phone_number = c.phone_number ORDER BY c2.id DESC LIMIT 1) as last_message,
+ (SELECT COUNT(*) FROM conversations c3 WHERE c3.phone_number = c.phone_number AND c3.direction = 'inbound' AND c3.id > COALESCE((SELECT MAX(c4.id) FROM conversations c4 WHERE c4.phone_number = c.phone_number AND c4.direction = 'outbound'), 0)) as unread_count
+ FROM conversations c
+ GROUP BY c.phone_number, c.contact_name, c.company_id
+ ORDER BY last_time DESC
+ ")->fetchAll();
+
+ $companies = [];
+ foreach (CompanyRepository::findAll() as $co) {
+ $companies[(int)$co['id']] = $co['name'];
+ }
+
+ $conversations = [];
+ foreach ($rows as $r) {
+ $cid = (int)$r['company_id'];
+ $conversations[] = [
+ 'phone' => $r['phone_number'],
+ 'contact_name' => $r['contact_name'] ?? '',
+ 'company_name' => $companies[$cid] ?? '',
+ 'last_message' => mb_substr($r['last_message'] ?? '', 0, 80),
+ 'last_time' => $r['last_time'],
+ 'unread_count' => (int)$r['unread_count'],
+ ];
+ }
+
+ jsonResponse(200, ['conversations' => $conversations]);
+ }
+
+ // ─── API: mensajes de una conversación ───────────────────────────────────
+
+ public static function chatMessages(): void
+ {
+ SessionAuth::require();
+ $phone = trim($_GET['phone'] ?? '');
+ if ($phone === '') jsonResponse(400, ['error' => 'phone requerido']);
+
+ $stmt = db()->prepare("
+ SELECT direction, message_type, content, media_id, status, created_at
+ FROM conversations
+ WHERE phone_number = ?
+ ORDER BY id ASC
+ ");
+ $stmt->execute([$phone]);
+ $messages = $stmt->fetchAll();
+
+ // Also include outbound_queue items for this phone
+ $stmt2 = db()->prepare("
+ SELECT 'outbound' as direction, message_type, payload as content, NULL as media_id, status, created_at
+ FROM outbound_queue
+ WHERE to_number = ? AND status IN ('sent','delivered','failed')
+ AND id > COALESCE((SELECT MAX(c.id) FROM conversations c WHERE c.phone_number = ?), 0)
+ ORDER BY id ASC
+ ");
+ $stmt2->execute([$phone, $phone]);
+ $queueMsgs = $stmt2->fetchAll();
+
+ foreach ($queueMsgs as &$qm) {
+ $pl = json_decode($qm['content'], true);
+ $qm['content'] = $pl['text'] ?? $pl['caption'] ?? $qm['content'];
+ }
+
+ $all = array_merge($messages, $queueMsgs);
+ usort($all, fn($a, $b) => strcmp($a['created_at'] ?? '', $b['created_at'] ?? ''));
+
+ jsonResponse(200, ['messages' => $all]);
+ }
+
+ // ─── API: enviar mensaje desde el chat ───────────────────────────────────
+
+ public static function chatSend(): void
+ {
+ SessionAuth::require();
+ $input = json_decode(file_get_contents('php://input'), true);
+ $phone = trim($input['phone'] ?? '');
+ $text = trim($input['text'] ?? '');
+ if ($phone === '' || $text === '') {
+ jsonResponse(400, ['error' => 'phone y text requeridos']);
+ }
+
+ // Find company for this phone (lookup by latest conversation)
+ $stmt = db()->prepare("SELECT company_id FROM conversations WHERE phone_number = ? ORDER BY id DESC LIMIT 1");
+ $stmt->execute([$phone]);
+ $conv = $stmt->fetch();
+ $companyId = $conv ? (int)$conv['company_id'] : 1;
+
+ // Enqueue the message
+ $db = db();
+ $payload = json_encode(['text' => $text]);
+ $qStmt = $db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload, status, created_at) VALUES (?, ?, 'text', ?, 'queued', NOW())");
+ $qStmt->execute([$companyId, $phone, $payload]);
+
+ // Add to conversations
+ $msgId = 'admin_' . time() . '_' . $phone;
+ $cStmt = $db->prepare("INSERT INTO conversations (company_id, message_id, phone_number, direction, message_type, content, created_at) VALUES (?, ?, ?, 'outbound', 'text', ?, NOW())");
+ $cStmt->execute([$companyId, $msgId, $phone, $text]);
+
+ jsonResponse(200, ['status' => 'sent']);
+ }
}
diff --git a/admin/v1/WpWebhook.php b/admin/v1/WpWebhook.php
index 0f1c9fa..a7dd028 100644
--- a/admin/v1/WpWebhook.php
+++ b/admin/v1/WpWebhook.php
@@ -12,6 +12,9 @@ class WpWebhook
/** Payload crudo del webhook actual, compartido entre métodos. */
private static string $currentRaw = '';
+ /** Empresa identificada para el webhook actual. */
+ private static ?array $currentCompany = null;
+
// ─── GET: Verificación Meta ───────────────────────────────────────────────
/**
@@ -85,6 +88,8 @@ class WpWebhook
return;
}
+ self::resolveCompany($payload);
+
$entries = $payload['entry'] ?? [];
foreach ($entries as $entry) {
$changes = $entry['changes'] ?? [];
@@ -106,6 +111,57 @@ class WpWebhook
self::saveRawEvent($raw);
}
+ private static function resolveCompany(array $payload): void
+ {
+ $phoneNumberId = '';
+
+ foreach ($payload['entry'] ?? [] as $entry) {
+ foreach ($entry['changes'] ?? [] as $change) {
+ $metadata = $change['value']['metadata'] ?? [];
+ if (!empty($metadata['phone_number_id'])) {
+ $phoneNumberId = $metadata['phone_number_id'];
+ break 2;
+ }
+ }
+ }
+
+ if ($phoneNumberId === '') {
+ self::log('WARN', 'No se pudo identificar phone_number_id en el payload');
+ return;
+ }
+
+ self::$currentCompany = CompanyRepository::findByPhoneNumberId($phoneNumberId);
+
+ if (self::$currentCompany === null) {
+ self::log('WARN', "No hay empresa configurada para phone_number_id: {$phoneNumberId}");
+ } else {
+ self::log('INFO', "Mensaje enrutado a empresa: " . (self::$currentCompany['name'] ?? '?'));
+ }
+ }
+
+ // ─── Reenvío a empresa ────────────────────────────────────────────────────
+
+ private static function forwardToCompany(array $context, string $content, ?string $mediaId = null): void
+ {
+ if (self::$currentCompany === null) {
+ return;
+ }
+
+ $messageData = array_merge($context, [
+ 'content' => $content,
+ 'media_id' => $mediaId,
+ 'raw_payload' => self::$currentRaw,
+ ]);
+
+ $result = CompanyApiClient::forwardMessage(self::$currentCompany, $messageData);
+
+ if ($result['success']) {
+ self::log('INFO', "Mensaje reenviado a empresa: " . (self::$currentCompany['name'] ?? '?'));
+ } else {
+ self::log('ERROR', "Fallo reenvío a empresa: " . ($result['error'] ?? json_encode($result)));
+ }
+ }
+
// ─── Mensajes entrantes ───────────────────────────────────────────────────
private static function handleMessages(array $value): void
@@ -169,9 +225,11 @@ class WpWebhook
self::log('MSG', "[TEXT] {$ctx['from']} ({$ctx['name']}): $body");
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'text', $body);
self::saveConversation($ctx, $body);
+ self::forwardToCompany($ctx, $body);
- // ─── Aquí conectas con tu lógica de bot ──────────────────────────────
- // Ejemplo: BotHandler::process($ctx, $body);
+ if (self::$currentCompany !== null) {
+ BotRouter::route(self::$currentCompany, $ctx, $body, 'text');
+ }
}
private static function handleMedia(array $msg, array $ctx, string $type): void
@@ -184,6 +242,7 @@ class WpWebhook
self::log('MSG', "[" . strtoupper($type) . "] {$ctx['from']} | id=$mediaId mime=$mime caption=$caption");
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], $type, $preview);
self::saveConversation($ctx, $preview, $mediaId);
+ self::forwardToCompany($ctx, $preview, $mediaId);
}
private static function handleLocation(array $msg, array $ctx): void
@@ -195,6 +254,7 @@ class WpWebhook
self::log('MSG', "[LOCATION] {$ctx['from']} | lat=$lat lng=$lng name=$name");
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'location', $preview);
self::saveConversation($ctx, $preview);
+ self::forwardToCompany($ctx, $preview);
}
private static function handleInteractive(array $msg, array $ctx): void
@@ -208,9 +268,15 @@ class WpWebhook
$reply = [];
}
$preview = $iType . ': ' . json_encode($reply);
+ $replyId = $reply['id'] ?? '';
self::log('MSG', "[INTERACTIVE/$iType] {$ctx['from']} | reply=" . json_encode($reply));
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'interactive', $preview);
self::saveConversation($ctx, $preview);
+ self::forwardToCompany($ctx, $preview);
+
+ if (self::$currentCompany !== null && $replyId !== '') {
+ BotRouter::route(self::$currentCompany, $ctx, $replyId, 'interactive');
+ }
}
private static function handleButton(array $msg, array $ctx): void
@@ -218,9 +284,15 @@ class WpWebhook
$text = $msg['button']['text'] ?? '';
$payload = $msg['button']['payload'] ?? '';
$preview = "$text | $payload";
+ $replyId = $payload ?: $text;
self::log('MSG', "[BUTTON] {$ctx['from']} | text=$text payload=$payload");
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'button', $preview);
self::saveConversation($ctx, $preview);
+ self::forwardToCompany($ctx, $preview);
+
+ if (self::$currentCompany !== null && $replyId !== '') {
+ BotRouter::route(self::$currentCompany, $ctx, $replyId, 'button');
+ }
}
private static function handleReaction(array $msg, array $ctx): void
@@ -231,6 +303,7 @@ class WpWebhook
self::log('MSG', "[REACTION] {$ctx['from']} ({$ctx['name']}) | emoji=$emoji msg=$reactTo");
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'reaction', $preview);
self::saveConversation($ctx, $preview);
+ self::forwardToCompany($ctx, $preview);
}
// ─── Estados de mensajes enviados ────────────────────────────────────────
@@ -251,6 +324,17 @@ class WpWebhook
self::log('INFO', "[STATUS:$status] msg=$id recipient=$recipient ts=$ts");
}
self::saveWebhookLog('statuses', $recipient, '', $status, "msg_id:$id");
+
+ if (self::$currentCompany !== null) {
+ $statusData = [
+ 'message_id' => $id,
+ 'status' => $status,
+ 'recipient' => $recipient,
+ 'timestamp' => $ts,
+ 'errors' => $s['errors'] ?? null,
+ ];
+ CompanyApiClient::forwardStatus(self::$currentCompany, $statusData);
+ }
}
}
@@ -286,12 +370,14 @@ class WpWebhook
private static function saveConversation(array $ctx, string $content, ?string $mediaId = null): void
{
try {
+ $companyId = self::$currentCompany['id'] ?? null;
$stmt = db()->prepare("
INSERT IGNORE INTO conversations
- (message_id, phone_number, contact_name, direction, message_type, content, media_id, timestamp)
- VALUES (?, ?, ?, 'inbound', ?, ?, ?, ?)
+ (company_id, message_id, phone_number, contact_name, direction, message_type, content, media_id, timestamp)
+ VALUES (?, ?, ?, ?, 'inbound', ?, ?, ?, ?)
");
$stmt->execute([
+ $companyId,
$ctx['message_id'],
$ctx['from'],
$ctx['name'],
@@ -312,11 +398,12 @@ class WpWebhook
private static function saveNotification(int $referenceId, string $phone, string $preview): void
{
try {
+ $companyId = self::$currentCompany['id'] ?? null;
$stmt = db()->prepare("
- INSERT INTO notifications (type, reference_id, phone_number, message)
- VALUES ('new_message', ?, ?, ?)
+ INSERT INTO notifications (company_id, type, reference_id, phone_number, message)
+ VALUES (?, 'new_message', ?, ?, ?)
");
- $stmt->execute([$referenceId, $phone, mb_substr($preview, 0, 255)]);
+ $stmt->execute([$companyId, $referenceId, $phone, mb_substr($preview, 0, 255)]);
} catch (\PDOException $e) {
self::log('ERROR', 'DB saveNotification: ' . $e->getMessage());
}
@@ -330,12 +417,13 @@ class WpWebhook
string $preview
): void {
try {
+ $companyId = self::$currentCompany['id'] ?? null;
$stmt = db()->prepare("
INSERT INTO webhook_logs
- (event_field, from_number, contact_name, message_type, message_preview, raw_payload)
- VALUES (?, ?, ?, ?, ?, ?)
+ (company_id, event_field, from_number, contact_name, message_type, message_preview, raw_payload)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
");
- $stmt->execute([$field, $from, $name, $type, mb_substr($preview, 0, 500), self::$currentRaw]);
+ $stmt->execute([$companyId, $field, $from, $name, $type, mb_substr($preview, 0, 500), self::$currentRaw]);
} catch (\PDOException $e) {
self::log('ERROR', 'DB saveWebhookLog: ' . $e->getMessage());
}
diff --git a/bot-whatsapp-palmas360.html b/bot-whatsapp-palmas360.html
new file mode 100644
index 0000000..8b1fe88
--- /dev/null
+++ b/bot-whatsapp-palmas360.html
@@ -0,0 +1,1482 @@
+
+
+
+
+
+ Bot WhatsApp · PALMAS360
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🎯
+
+
1. Objetivo General
+
Qué se quiere lograr con este módulo
+
+
+
+
+
📲
+
Captura de campo sin app
+
Permitir que cualquier trabajador de una finca registre datos operativos (labores, cosecha, báscula) directamente desde WhatsApp, sin necesidad de instalar ninguna aplicación adicional.
+
+
+
🤖
+
Validación inteligente con IA
+
La IA actúa dentro de PALMAS360 , al momento en que el supervisor abre la solicitud para aprobar. Detecta errores, campos inconsistentes y sugiere correcciones. El bot de WhatsApp solo guía la captura con un menú estructurado.
+
+
+
✅
+
Cero ingresos sin supervisión
+
Ningún dato llega directamente a la base de datos. Siempre pasa por un dashboard de aprobación. El supervisor edita, valida y autoriza antes de guardar.
+
+
+
🔔
+
Notificaciones automatizadas
+
Los supervisores programan tareas en PALMAS360 y, al activar "Notificar", el sistema envía automáticamente el aviso por WhatsApp a los usuarios asignados.
+
+
+
📊
+
Informes por WhatsApp
+
Los clientes pueden solicitar informes escribiendo en lenguaje natural. La IA interpreta la solicitud, consulta el ERP y devuelve el informe directamente al chat.
+
+
+
🏢
+
Multi-tenant seguro
+
Un mismo número de WhatsApp Business atiende a todos los clientes (empresas). El sistema identifica a qué empresa pertenece cada número y dirige los datos al tenant correcto.
+
+
+
+
+
+
+
+
+
+
📐
+
+
2. Alcance del Módulo
+
Qué está dentro y qué está fuera del alcance inicial
+
+
+
+
+
+
+ Funcionalidad
+ Dentro del alcance
+ Fuera del alcance v1
+
+
+
+
+ Captura de datos de campo
+ Labores diarias, ciclos de cosecha, tiquetes de báscula
+ Otros módulos del ERP (nómina, fertilización…)
+
+
+ Identificación de usuario
+ Por número de teléfono registrado en la empresa
+ Autenticación biométrica / 2FA por WhatsApp
+
+
+ Routing multi-tenant
+ Despacho automático al tenant correcto (≈30 empresas)
+ Creación de nuevos tenants desde WhatsApp
+
+
+ Dashboard de aprobación
+ Revisión, edición y aprobación por el supervisor
+ Aprobación masiva sin revisión
+
+
+ Validación con IA
+ Errores de parametrización, lotes duplicados, campos faltantes
+ Lógica de negocio financiera compleja
+
+
+ Notificaciones salientes
+ Tareas programadas desde PALMAS360 → WhatsApp
+ Campañas de marketing / mensajes masivos
+
+
+ Informes por chat
+ Informes disponibles en el ERP, vía lenguaje natural
+ Generación de informes ad-hoc nuevos
+
+
+ Log de WhatsApp
+ Historial de mensajes por empresa y por número
+ Archivado de multimedia (imágenes, audios)
+
+
+
+
+
+
+
+
🛑
+
+
Límite de entrega: el click en “Guardar”
+
+ El requerimiento de este módulo cubre desde que el mensaje llega por WhatsApp hasta que el supervisor hace click en Guardar en el dashboard de aprobación.
+ A partir de ese click, el flujo es el de PALMAS360 existente : la misma lógica de negocio, las mismas tablas y las mismas validaciones que ya funcionan en el ERP.
+ Lo nuevo es: WhatsApp API → Webhook → Router → Dashboard de aprobación → click Guardar → tablas existentes del ERP.
+
+
+
+
+
+
+
+
+
+
+
+
🏗️
+
+
3. Arquitectura del Sistema
+
Componentes principales y cómo se relacionan
+
+
+
+
+
+graph LR
+ subgraph Campo["📱 Campo / Usuario"]
+ WA["WhatsApp\nUsuario de campo"]
+ end
+
+ subgraph Gateway["☁️ Gateway"]
+ WABA["WhatsApp\nBusiness API\n(Meta Cloud API)"]
+ WH["Webhook\nReceiver"]
+ end
+
+ subgraph Core["⚙️ PALMAS360 Core (Multi-tenant)"]
+ ROUTER["Router\nde Tenant\n(por número)"]
+ AI["Motor IA\n(LLM + RAG)"]
+ DASH["Dashboard\nAprobación"]
+ SCHED["Planificador\nde Tareas"]
+ LOG["Log\nWhatsApp"]
+ end
+
+ subgraph Tenants["🏢 Tenants (30+ empresas)"]
+ T1["app.palmas360.com\n/rosablanca"]
+ T2["app.palmas360.com\n/AMG"]
+ TN["app.palmas360.com\n/..."]
+ end
+
+ WA -->|"Mensaje"| WABA
+ WABA -->|"POST webhook"| WH
+ WH --> ROUTER
+ ROUTER --> AI
+ AI --> DASH
+ DASH --> T1 & T2 & TN
+ SCHED -->|"Envío automático"| WABA
+ LOG -.->|"Auditoría"| T1 & T2 & TN
+
+ style Campo fill:#e8f5eb,stroke:#2aa147
+ style Gateway fill:#fff3e0,stroke:#f4b942
+ style Core fill:#e3f2fd,stroke:#1976d2
+ style Tenants fill:#f3e5f5,stroke:#7b1fa2
+
+
Figura 1 · Arquitectura general del Bot WhatsApp para PALMAS360
+
+
+
+
+
📱
+
WhatsApp Business API
+
Canal de comunicación bidireccional. Recibe mensajes entrantes y envía respuestas y notificaciones.
+
+
+
🔀
+
Router de Tenant
+
Identifica el número de teléfono en la tabla de usuarios registrados y determina a qué empresa pertenece.
+
+
+
🤖
+
Motor IA (LLM)
+
Actúa dentro del dashboard de PALMAS360: analiza los datos capturados por el bot, detecta inconsistencias y presenta alertas y sugerencias al supervisor antes de aprobar.
+
+
+
🖥️
+
Dashboard de Aprobación
+
Interfaz dentro de PALMAS360 donde el supervisor revisa, corrige y aprueba cada solicitud antes de guardarla.
+
+
+
📅
+
Planificador de Tareas
+
Permite programar tareas en el ERP y disparar automáticamente un mensaje de WhatsApp al activar "Notificar".
+
+
+
📋
+
Log de WhatsApp
+
Sección del ERP que muestra el historial completo de conversaciones, agrupadas por empresa y por número de teléfono.
+
+
+
+
+
+
+
+
+
+
📥
+
+
4. Flujo · Captura de Datos de Campo
+
Doble vía: menú guiado paso a paso, o texto libre (un párrafo de corrido) que la IA interpreta y organiza en el dashboard de PALMAS360
+
+
+
+
+
+flowchart TD
+ A([👷 Usuario escribe\ncualquier mensaje]) --> B{¿Número\nregistrado?}
+ B -->|No| C[🤖 Bot: Número no registrado.\nContacta al administrador]
+ B -->|Sí| D[Identificar empresa\npor número de teléfono]
+ D --> TX{¿Es texto\nlibre o saludo?}
+
+ TX -->|"Párrafo / texto\nde corrido"| TL1["🤖 Bot: ✅ Recibido.\nEnviando a revisión..."]
+ TL1 --> TL2[📨 Mensaje raw guardado\nen PALMAS360]
+ TL2 --> TL3["🤖 IA en dashboard\ninterpreta y organiza\nlos campos del texto"]
+ TL3 --> KK[🔔 Supervisor notificado\npara revisar y aprobar]
+
+ TX -->|"Saludo / número\nde opción"| E["🤖 Bot envía menú:\n1️⃣ Labor Diaria\n2️⃣ Ciclo de Cosecha\n3️⃣ Tiquete de Báscula\n4️⃣ Solicitar Informe\n5️⃣ Cancelar"]
+ E --> F{Usuario\nresponde}
+ F -->|"1"| G1["🤖 Bot pregunta paso a paso:\n¿Fecha? → ¿Lote?\n¿Labor? → ¿N° trabajadores?\n¿Palmas atendidas?"]
+ F -->|"2"| G2["🤖 Bot pregunta paso a paso:\n¿Fecha? → ¿Lote?\n¿N° ciclo? → ¿Racimos?\n¿Peso estimado?"]
+ F -->|"3"| G3["🤖 Bot pregunta paso a paso:\n¿N° tiquete? → ¿Placa?\n¿Peso bruto? → ¿Tara?\n¿Extractora destino?"]
+ F -->|"4"| G4[Ver flujo\nSolicitud de Informe]
+ F -->|"5"| ZZ([🤖 Cancelado. Escribe\ncuando quieras continuar])
+ G1 & G2 & G3 --> H[Usuario completa\ntodas las respuestas]
+ H --> I["🤖 Bot muestra resumen:\n¿Confirmas estos datos?\n✅ SI / ❌ NO"]
+ I -->|NO| E
+ I -->|SI| J[✅ Dato guardado como\nPENDIENTE en PALMAS360]
+ J --> K[🔔 Notifica al supervisor\nen PALMAS360 para aprobar]
+ KK & K --> L["🤖 Bot confirma al usuario:\nRegistro enviado.\nEsperando aprobación"]
+
+ style A fill:#e8f5eb,stroke:#2aa147
+ style C fill:#fee2e2,stroke:#b91c1c
+ style TX fill:#fff3e0,stroke:#f4b942
+ style TL2 fill:#e3f2fd,stroke:#1976d2
+ style TL3 fill:#fff3e0,stroke:#f4b942
+ style E fill:#e3f2fd,stroke:#1976d2
+ style J fill:#e8f5eb,stroke:#2aa147
+ style K fill:#e3f2fd,stroke:#1976d2
+ style KK fill:#e3f2fd,stroke:#1976d2
+ style ZZ fill:#f5f5f5,stroke:#999
+
+
Figura 2 · Flujo de captura: menú guiado o texto libre interpretado por IA en dashboard
+
+
+
+
+
+
+
+
+
+
Hola
+
+ Hola Carlos 👋
+ Empresa: Rosa Blanca
+ ¿Qué deseas registrar?
+ 1️⃣ Labor Diaria
+ 2️⃣ Ciclo de Cosecha
+ 3️⃣ Tiquete de Báscula
+ 4️⃣ Solicitar Informe
+ 5️⃣ Cancelar
+
+
+
Paso 1 · Menú de opciones
+
+
+
+
+
+
+
1
+
📋 Labor Diaria ¿Cuál es la fecha ? (dd/mm/aaaa)
+
25/05/2026
+
¿Número o nombre del lote ?
+
Lote 7
+
¿Qué labor se realizó?
+
Poda
+
+
Paso 2 · Preguntas guiadas
+
+
+
+
+
+
+
+ ✅ Resumen:
+ 📅 25/05/2026 · Lote 7
+ Labor: Poda
+ Trabajadores: 4 · Palmas: 120
+ ¿Confirmas? Responde SI o NO
+
+
SI
+
+ ✅ Registro enviado correctamente.
+ Pendiente de aprobación en PALMAS360 por tu supervisor.
+
+
+
Paso 3 · Confirmación y envío
+
+
+
+
+
+
+
Hoy poda lote 7 con 4 trabajadores, hicimos 120 palmas, todo bien sin novedad
+
+ ✅ Recibido.
+ Tu mensaje fue enviado al dashboard.
+ La IA interpretará los datos y el supervisor los revisará antes de guardar.
+
+
+
Alt · Texto libre → IA interpreta
+
+
+
+
+
+
+
💡
+
+
Dos formas de capturar datos
+
+ Menú guiado: el usuario elige una opción numerada y responde preguntas una a una. Datos llegan organizados al dashboard.
+ Texto libre: el usuario escribe un párrafo de corrido con toda la información. El bot lo recibe, lo reenvía al dashboard y la IA lo interpreta, extrae y organiza los campos automáticamente para que el supervisor los revise.
+
+
+
+
+
+
+
📋
+
Labores Diarias
+
El bot solicita paso a paso: fecha, lote, labor realizada, número de trabajadores, palmas atendidas y observaciones. Todo por preguntas guiadas.
+
+
+
🌴
+
Ciclos de Cosecha
+
El bot solicita: fecha, lote, número de ciclo, cantidad de racimos, peso estimado y nombre del cortero responsable.
+
+
+
⚖️
+
Tiquetes de Báscula
+
El bot solicita: número de tiquete, placa del vehículo, peso bruto, tara, peso neto y extractora destino.
+
+
+
+
+
+
+
+
+
+
🖥️
+
+
5. Flujo · Dashboard IA y Aprobación
+
Datos estructurados del menú, o texto libre: la IA interpreta, organiza y asiste al supervisor antes de guardar cualquier registro
+
+
+
+
+
+flowchart TD
+ A(["📥 Solicitud llega al\nDashboard de Aprobación"]) --> B{¿Origen\ndel dato?}
+
+ B -->|"Menú guiado\n(campos estructurados)"| C1[📄 Vista con campos\nya organizados]
+ B -->|"Texto libre\n(párrafo de corrido)"| C2["🤖 IA lee el texto raw,\nextrae e interpreta\ncada campo"]
+ C2 --> C3["📝 Vista IA: campo extraído\n+ confianza + texto original\npara que supervisor compare"]
+ C1 --> D
+ C3 --> D
+ D["🔍 Panel IA valida:\n• ¿Lote existe?\n• ¿Duplicado?\n• ¿Valores en rango?\n• ¿Campos faltantes?"] --> E{Decisión\ndel supervisor}
+ E -->|Aprobar sin cambios| F[✅ Guardar en PALMAS360]
+ E -->|Editar y aprobar| G[✏️ Supervisor corrige\ncampos en el editor]
+ G --> F
+ E -->|Rechazar| H[❌ Marcar como rechazado]
+ H --> I[🤖 Notificar al usuario\npor WhatsApp el motivo]
+ F --> J[📊 Dato disponible\nen todos los módulos\ndel ERP]
+ F --> K[🤖 Confirmación al usuario\npor WhatsApp]
+
+ style A fill:#e3f2fd,stroke:#1976d2
+ style B fill:#fff3e0,stroke:#f4b942
+ style C2 fill:#fff3e0,stroke:#f4b942
+ style C3 fill:#fff3e0,stroke:#f4b942
+ style D fill:#fff3e0,stroke:#f4b942
+ style F fill:#e8f5eb,stroke:#2aa147
+ style H fill:#fee2e2,stroke:#b91c1c
+ style J fill:#e8f5eb,stroke:#2aa147
+
+
Figura 3 · Dashboard de Aprobación: menú estructurado o texto libre interpretado por IA
+
+
+
+
+
V1
+
+
Lote ya registrado
+
La IA alerta si el lote ya tiene un ciclo abierto para la misma fecha, evitando duplicados.
+
+
+
+
V2
+
+
Campos obligatorios faltantes
+
Detecta si faltan campos requeridos según la parametrización del tenant (p.ej. lote, fecha, labor).
+
+
+
+
V3
+
+
Valores fuera de rango
+
Identifica pesos, cantidades o fechas que no corresponden a rangos históricos esperados.
+
+
+
+
V4
+
+
Lote o labor no parametrizados
+
Verifica que el lote y la labor existan en el catálogo activo de la empresa.
+
+
+
+
V5
+
+
Usuario sin permisos
+
Confirma que el número de teléfono tiene autorización para registrar el tipo de dato enviado.
+
+
+
+
V6
+
+
Ciclo de cosecha inactivo
+
Advierte si el ciclo al que se intenta cargar producción no está activo en el periodo actual.
+
+
+
+
+
+
+
+
+
+
+
🔔
+
+
6. Flujo · Notificaciones y Tareas Programadas
+
Envíos automáticos de WhatsApp disparados desde PALMAS360
+
+
+
+
+
+sequenceDiagram
+ actor SUP as 👤 Supervisor
+ participant P360 as 🖥️ PALMAS360
+ participant SCHED as ⏰ Planificador
+ participant WABA as 📲 WhatsApp API
+ participant USR as 👷 Usuario de campo
+
+ SUP->>P360: Crea tarea (ej: "Aplicar fertilizante Lote 12 el 28/05")
+ SUP->>P360: Asigna usuarios y activa "Notificar por WhatsApp"
+ P360->>SCHED: Registra job programado (fecha/hora)
+ Note over SCHED: El planificador espera la fecha/hora configurada
+ SCHED->>WABA: Dispara mensaje en la fecha indicada
+ WABA->>USR: 📩 "Recordatorio: Aplicar fertilizante\nLote 12 mañana 28/05\nEmpresa Rosa Blanca"
+ USR->>WABA: ✅ "Confirmado"
+ WABA->>P360: Registra confirmación en el log
+ P360->>SUP: Actualiza estado de la tarea
+
+
Figura 4 · Diagrama de secuencia para notificaciones programadas
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📝
+
+
8. Requerimientos Funcionales
+
Listado priorizado de funcionalidades a implementar
+
+
+
+
+
+
+ #
+ Módulo
+ Requerimiento
+ Prioridad
+
+
+
+
+ RF-01
+ Identificación
+ Registrar números de WhatsApp por usuario y por empresa en PALMAS360
+ Alta
+
+
+ RF-02
+ Routing
+ Enrutar automáticamente cada mensaje al tenant correcto según el número remitente
+ Alta
+
+
+ RF-03
+ Captura
+ Capturar datos por dos vías: (a) menú interactivo con preguntas paso a paso, o (b) texto libre donde el usuario escribe un párrafo de corrido y el bot lo recibe y reenvía al dashboard
+ Alta
+
+
+ RF-04
+ IA
+ Aplicar IA (LLM) en el dashboard de PALMAS360 para: interpretar y extraer campos de mensajes de texto libre, validar datos estructurados, detectar errores y asistir al supervisor en la aprobación
+ Alta
+
+
+ RF-05
+ Validación IA
+ Validar datos contra la parametrización del tenant (lotes, ciclos, usuarios, rangos)
+ Alta
+
+
+ RF-06
+ Dashboard
+ Interfaz de aprobación en PALMAS360 con editor de campos y panel de alertas de IA
+ Alta
+
+
+ RF-07
+ Aprobación
+ Nada se guarda en el ERP sin aprobación explícita del supervisor
+ Alta
+
+
+ RF-08
+ Log
+ Sección en PALMAS360 con historial de conversaciones WhatsApp por empresa y número
+ Alta
+
+
+ RF-09
+ Notificaciones
+ Programar envíos de WhatsApp desde el módulo de tareas de PALMAS360
+ Media
+
+
+ RF-10
+ Informes
+ Procesar solicitudes de informes en lenguaje natural y entregar respuesta por WhatsApp
+ Media
+
+
+ RF-11
+ Permisos
+ Control de acceso por número: qué puede registrar y qué informes puede solicitar
+ Media
+
+
+ RF-12
+ Confirmaciones
+ Enviar confirmación al usuario por WhatsApp cuando su dato sea aprobado o rechazado
+ Media
+
+
+ RF-13
+ Multi-idioma
+ Soporte para mensajes en español colombiano con variantes regionales
+ Baja
+
+
+ RF-14
+ Multimedia
+ Aceptar fotos de tiquetes y extraer datos con OCR + IA
+ Baja
+
+
+
+
+
+
+
+
+
+
+
+
🚀
+
+
9. Fases de Implementación
+
Plan de desarrollo en etapas priorizadas
+
+
+
+
+
+
+
+
FASE 1 · Fundación
+
Integración WhatsApp + Identificación de usuarios
+
Configurar WhatsApp Business API (Meta Cloud API), crear webhook receptor, implementar tabla de números de teléfono por tenant, desarrollar el router multi-tenant y habilitar el log de conversaciones en PALMAS360.
+
+
+
+
+
+
+
FASE 2 · Captura de datos
+
Motor IA de extracción y validación
+
Implementar el motor de conversación del bot: menú interactivo, preguntas guiadas paso a paso y validación básica de formato de respuesta. Definir esquemas de datos para labores diarias, ciclos de cosecha y tiquetes de báscula. Integrar IA (LLM) en el dashboard de PALMAS360 para asistir al supervisor en la aprobación.
+
+
+
+
+
+
+
FASE 3 · Dashboard
+
Interfaz de aprobación supervisada
+
Construir el dashboard dentro de PALMAS360: listado de solicitudes pendientes, vista de detalle con campos editables, panel de alertas y sugerencias de IA, botones de aprobar / rechazar y envío de confirmaciones al usuario.
+
+
+
+
+
+
+
FASE 4 · Notificaciones
+
Planificador de tareas con envío automático
+
Extender el módulo de tareas de PALMAS360 con opción "Notificar por WhatsApp". Implementar scheduler (cron jobs) que dispara mensajes en la fecha/hora programada y registra el estado de envío y confirmación.
+
+
+
+
+
+
+
FASE 5 · Informes
+
Consultas de informes por lenguaje natural
+
Entrenar / prompt-engineer al LLM para reconocer intenciones de tipo "informe". Conectar con los endpoints de reportes existentes en el ERP. Formatear la respuesta en texto estructurado para WhatsApp y gestionar control de acceso por número.
+
+
+
+
+
+
+
+
+
+
+
+
🗃️
+
+
10. Tablas Nuevas del Módulo Bot
+
Solo se crean 3 tablas nuevas. Las tablas del ERP ya existen y no se modifican
+
+
+
+
+
+
+
✅ NUEVO · Se crea en este proyecto
+
+ wa_usuarios — Números WhatsApp por empresa
+ wa_log — Historial de conversaciones
+ wa_solicitudes — Cola de aprobación del dashboard
+
+
+
+
📚 YA EXISTE · Tablas del ERP (no se tocan)
+
+ labores_diarias — Módulo labores
+ ciclos_cosecha — Módulo cosecha
+ tiquetes_bascula — Módulo báscula
+ ...resto de tablas del proyecto
+
+
+
+
+
+
+graph LR
+ subgraph NEW[" NUEVAS - Se crean en este proyecto "]
+ WU["wa_usuarios"]
+ WL["wa_log"]
+ WS["wa_solicitudes"]
+ end
+ subgraph EXIST[" YA EXISTEN - Tablas PALMAS360 ERP "]
+ LD["labores_diarias"]
+ CC["ciclos_cosecha"]
+ TB["tiquetes_bascula"]
+ end
+ WU -->|genera| WL
+ WL -->|origina| WS
+ WS -.->|click Guardar| LD
+ WS -.->|click Guardar| CC
+ WS -.->|click Guardar| TB
+ style NEW fill:#e8f5eb,stroke:#2aa147,color:#1e293b
+ style EXIST fill:#f1f5f9,stroke:#94a3b8,color:#64748b
+ style WU fill:#ffffff,stroke:#2aa147,color:#1e293b
+ style WL fill:#ffffff,stroke:#2aa147,color:#1e293b
+ style WS fill:#ffffff,stroke:#2aa147,color:#1e293b
+ style LD fill:#e2e8f0,stroke:#94a3b8,color:#64748b
+ style CC fill:#e2e8f0,stroke:#94a3b8,color:#64748b
+ style TB fill:#e2e8f0,stroke:#94a3b8,color:#64748b
+
+
Figura 6 · Solo 3 tablas nuevas (verde). Las tablas del ERP ya existen (gris) y solo reciben el dato cuando el supervisor hace click en Guardar
+
+
+
+
+
+
👤 wa_usuarios
+
+ telefono (PK)
+ empresa_slug
+ nombre
+ rol (supervisor / trabajador)
+ activo
+
+
+
+
📝 wa_log
+
+ id (PK)
+ telefono_origen
+ empresa_slug
+ timestamp
+ mensaje_raw
+ tipo (entrada / salida)
+ estado
+
+
+
+
⏳ wa_solicitudes
+
+ id (PK)
+ wa_log_id (FK)
+ tipo (labor / cosecha / bascula)
+ origen (menu / texto_libre)
+ datos_json
+ texto_raw
+ alertas_ia
+ estado (pendiente / aprobado / rechazado)
+ aprobado_por
+ fecha_aprobacion
+
+
+
+
+
+
+
Flujo completo de datos: de WhatsApp a la BD existente
+
+
📲 MensajeWhatsApp
+
→
+
📋 wa_logHistorial
+
→
+
⏳ wa_solicitudesPendiente
+
→
+
🖥️ DashboardIA + Supervisor
+
→
+
✅ Click GuardarLímite del requerimiento
+
→
+
📚 BD existentePALMAS360 ERP
+
+
+ 🟣 Todo lo que está a la derecha del click Guardar es responsabilidad del cliente / flujo existente del ERP.
+
+
+
+
+
+
+
+
+
+
Si está de acuerdo con esta estructura y alcance definidos
+
+ 📄 Ver Propuesta Comercial
+
+
+
+
+
+
+
+
+
+
+
+ 🌴 PALMAS360 · Propuesta Comercial
+
+
Módulo Bot WhatsApp + IA
+
Fecha: 25 de mayo de 2026 · Válida hasta el 31 de mayo de 2026
+
+
+
+
+
✅ Alcances Incluidos
+
+
✓ Integración con WhatsApp Business API (Meta Cloud API) para recibir y enviar mensajes
+
✓ Router multi-tenant: identificación automática de la empresa por número de teléfono (~30 empresas)
+
✓ Bot con menú interactivo y captura guiada paso a paso (Labor Diaria, Ciclo de Cosecha, Tiquete de Báscula)
+
✓ Recepción de texto libre : el usuario escribe un párrafo y la IA lo interpreta en el dashboard
+
✓ Dashboard de aprobación en PALMAS360: editor de campos, panel IA, aprobar / rechazar
+
✓ Validación IA: lotes duplicados, campos faltantes, valores fuera de rango, ciclos inactivos
+
✓ Notificaciones programadas : tareas creadas en PALMAS360 se envían automáticamente por WhatsApp
+
✓ Log de conversaciones WhatsApp visible en PALMAS360 por empresa y por número
+
✓ Creación de 3 tablas nuevas en la BD existente: wa_usuarios, wa_log, wa_solicitudes
+
✓ Confirmaciones al usuario por WhatsApp cuando su registro sea aprobado o rechazado
+
+
+
+
📊 Consulta de informes por WhatsApp — hasta 5 informes pre-existentes en PALMAS360:
+
+ 1. Producción diaria / semanal
+ 2. Labores por trabajador
+ 3. Tiquetes de báscula
+ 4. Rendimientos de cosecha
+ 5. A definir con el cliente
+
+
El informe debe existir previamente en PALMAS360. Informes adicionales incrementan el valor.
+
+
+
+
+
+
⚠️ Requisitos a cargo del Cliente
+
+
+
📲
+
WhatsApp Manager activo + App de Facebook funcionando El cliente debe tener configurada su cuenta de Meta Business con un número de teléfono aprobado para WhatsApp Business API.
+
+
+
🔗
+
Reapuntar webhook a la URL proporcionada El cliente debe configurar en Meta Developer Console el webhook de WhatsApp hacia la URL que le suministremos. Esto requiere acceso de administrador a la app de Meta.
+
+
+
🤖
+
API Key de una IA (OpenAI, Gemini, Anthropic, etc.) El cliente elige y contrata su proveedor de IA. La calidad de la interpretación de mensajes y la clasificación de los eventos depende directamente del modelo y plan elegido. Nosotros configuramos la integración.
+
+
+
+
+
+
+
+
+
+
💰 Valor del Proyecto
+
+ Valor base
+ $13.800.000
+
+
+ Descuento partner (50%)
+ − $6.900.000
+
+
+ Valor final
+ $6.900.000
+
+
+
+ 50% Anticipo (inicio)
+ $3.450.000
+
+
+ 50% Al finalizar (aprobación cliente)
+ $3.450.000
+
+
+
+
+
+
+
📅 Ejecución
+
+
● Duración: 30 días calendario
+
● Inicio: cualquier lunes, una vez recibida la orden de servicio y el 50% de anticipo
+
● Avances: entrega semanal por fases
+
● Cierre: el proyecto finaliza una vez el cliente revise y apruebe
+
● Garantía: 30 días post-producción
+
+
+
+
+
+
+
+
⚠️ Condiciones importantes:
+
+ Si el alcance cambia durante la ejecución, el precio y el cronograma se ajustan antes de continuar.
+ El proyecto no inicia sin la orden de servicio firmada y el 50% de anticipo recibido.
+ El saldo del 50% se factura una vez el cliente aprueba el entregable final.
+
+
+
+
+
+
+ ✕ Cerrar propuesta
+
+
+
+
+
+
+
+
+
+ PALMAS360 · ERP para Palmicultura · Módulo Bot WhatsApp + IA | Documento generado el 25 de mayo de 2026
+
+
+
+
+
+
diff --git a/public/index.php b/public/index.php
index 3d7ab94..8d56e16 100644
--- a/public/index.php
+++ b/public/index.php
@@ -3,10 +3,37 @@ declare(strict_types=1);
require_once __DIR__ . '/../config/env.php';
require_once __DIR__ . '/../config/db.php';
+require_once __DIR__ . '/../services/Settings.php';
+
+// Merge DB settings over .env defaults (so env() always returns the latest)
+(function () {
+ try {
+ $rows = db()->query("SELECT `key`, `value` FROM settings")->fetchAll();
+ foreach ($rows as $r) {
+ if ($r['value'] !== null && $r['value'] !== '') {
+ $_ENV[$r['key']] = $r['value'];
+ putenv("{$r['key']}={$r['value']}");
+ }
+ }
+ } catch (\Throwable $e) {
+ // DB not ready yet — fallback to .env only
+ }
+})();
require_once __DIR__ . '/../admin/v1/WpWebhook.php';
require_once __DIR__ . '/../middleware/SessionAuth.php';
require_once __DIR__ . '/../admin/LoginController.php';
require_once __DIR__ . '/../admin/DashboardController.php';
+require_once __DIR__ . '/../services/CompanyRepository.php';
+require_once __DIR__ . '/../services/CompanyApiClient.php';
+require_once __DIR__ . '/../services/WhatsAppSender.php';
+require_once __DIR__ . '/../services/OutboundWorker.php';
+require_once __DIR__ . '/../services/ErpSync.php';
+require_once __DIR__ . '/../services/ConversationContext.php';
+require_once __DIR__ . '/../services/NormalBot.php';
+require_once __DIR__ . '/../services/AiBot.php';
+require_once __DIR__ . '/../services/BotRouter.php';
+require_once __DIR__ . '/../services/PendingApproval.php';
+require_once __DIR__ . '/../services/ErpMonitor.php';
// ─── Helper de respuesta JSON ────────────────────────────────────────────────
function jsonResponse(int $status, array $body): void
@@ -55,11 +82,238 @@ $routes = [
['GET', '/admin/live', fn() => DashboardController::live()],
['GET', '/admin/webhook/stream', fn() => DashboardController::stream()],
['GET', '/admin/webhook/raw', fn() => DashboardController::getRaw()],
+ ['GET', '/admin/chat', fn() => DashboardController::chat()],
+ ['GET', '/admin/chat/conversations', fn() => DashboardController::chatConversations()],
+ ['GET', '/admin/chat/messages', fn() => DashboardController::chatMessages()],
+ ['POST', '/admin/chat/send', fn() => DashboardController::chatSend()],
// ─── Páginas legales (requeridas por Meta/WhatsApp Business) ────────────
['GET', '/politicas', fn() => serveHtml('politicas.html')],
['GET', '/eliminacion-datos-usuario', fn() => serveHtml('eliminacion-datos-usuario.html')],
['GET', '/condiciones-servicio', fn() => serveHtml('condiciones-servicio.html')],
+
+ // ─── API de envío (outbound) — autenticada por API Key de la empresa ────
+ ['POST', '/api/send', fn() => (function () {
+ $apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
+ if ($apiKey === '') {
+ jsonResponse(401, ['error' => 'X-API-Key header requerido']);
+ }
+ $company = CompanyRepository::findByApiKey($apiKey);
+ if ($company === null) {
+ jsonResponse(403, ['error' => 'API Key inválida']);
+ }
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!is_array($input)) {
+ jsonResponse(400, ['error' => 'JSON inválido']);
+ }
+ $to = trim($input['to'] ?? '');
+ $type = $input['type'] ?? 'text';
+ if ($to === '') {
+ jsonResponse(400, ['error' => 'Campo "to" requerido']);
+ }
+
+ $payload = match ($type) {
+ 'text' => ['text' => $input['text'] ?? ''],
+ 'image' => ['media_id' => $input['media_id'] ?? '', 'url' => $input['url'] ?? '', 'caption' => $input['caption'] ?? ''],
+ 'template' => ['template_name' => $input['template_name'] ?? '', 'components' => $input['components'] ?? []],
+ 'interactive' => ['interactive' => $input['interactive'] ?? []],
+ default => jsonResponse(400, ['error' => "Tipo no soportado: {$type}"]),
+ };
+
+ $db = db();
+ $stmt = $db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload) VALUES (?, ?, ?, ?)");
+ $stmt->execute([$company['id'], $to, $type, json_encode($payload)]);
+ $queueId = (int)$db->lastInsertId();
+
+ jsonResponse(200, ['status' => 'queued', 'id' => $queueId]);
+ })()],
+
+ // ─── ERP: listar mensajes pendientes de aprobación ──────────────────────
+ ['GET', '/api/pending', fn() => (function () {
+ $apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
+ if ($apiKey === '') {
+ jsonResponse(401, ['error' => 'X-API-Key header requerido']);
+ }
+ $company = CompanyRepository::findByApiKey($apiKey);
+ if ($company === null) {
+ jsonResponse(403, ['error' => 'API Key inválida']);
+ }
+ $items = PendingApproval::findByCompany((int)$company['id'], 'pending');
+ jsonResponse(200, ['pending' => $items, 'total' => count($items)]);
+ })()],
+
+ // ─── ERP: aprobar o rechazar un mensaje pendiente ───────────────────────
+ ['POST', '/api/approval', fn() => (function () {
+ $apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
+ if ($apiKey === '') {
+ jsonResponse(401, ['error' => 'X-API-Key header requerido']);
+ }
+ $company = CompanyRepository::findByApiKey($apiKey);
+ if ($company === null) {
+ jsonResponse(403, ['error' => 'API Key inválida']);
+ }
+
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!is_array($input)) {
+ jsonResponse(400, ['error' => 'JSON inválido']);
+ }
+
+ $pendingId = (int)($input['pending_id'] ?? 0);
+ $action = $input['action'] ?? ''; // approve | reject
+ $note = $input['note'] ?? null;
+
+ if ($pendingId === 0) {
+ jsonResponse(400, ['error' => 'pending_id requerido']);
+ }
+
+ if ($action === 'approve') {
+ $item = PendingApproval::approve($pendingId, 'ERP: ' . ($company['name'] ?? ''), $note);
+ if ($item === null) {
+ jsonResponse(404, ['error' => 'Item no encontrado o ya procesado']);
+ }
+ jsonResponse(200, ['status' => 'approved', 'pending_id' => $pendingId, 'message' => 'Respuesta aprobada y encolada para envío']);
+ } elseif ($action === 'reject') {
+ $item = PendingApproval::reject($pendingId, 'ERP: ' . ($company['name'] ?? ''), $note);
+ if ($item === null) {
+ jsonResponse(404, ['error' => 'Item no encontrado o ya procesado']);
+ }
+ jsonResponse(200, ['status' => 'rejected', 'pending_id' => $pendingId, 'message' => 'Mensaje rechazado']);
+ } else {
+ jsonResponse(400, ['error' => "Acción no soportada: {$action}. Usa 'approve' o 'reject'"]);
+ }
+ })()],
+
+ // ─── ERP: enviar mensaje directo (outbound legacy) ──────────────────────
+ ['POST', '/api/send-direct', fn() => (function () {
+ $apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
+ if ($apiKey === '') {
+ jsonResponse(401, ['error' => 'X-API-Key header requerido']);
+ }
+ $company = CompanyRepository::findByApiKey($apiKey);
+ if ($company === null) {
+ jsonResponse(403, ['error' => 'API Key inválida']);
+ }
+
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!is_array($input)) {
+ jsonResponse(400, ['error' => 'JSON inválido']);
+ }
+ $to = trim($input['to'] ?? '');
+ $type = $input['type'] ?? 'text';
+ if ($to === '') {
+ jsonResponse(400, ['error' => 'Campo "to" requerido']);
+ }
+
+ $payload = match ($type) {
+ 'text' => json_encode(['text' => $input['text'] ?? '']),
+ 'image' => json_encode(['media_id' => $input['media_id'] ?? '', 'url' => $input['url'] ?? '', 'caption' => $input['caption'] ?? '']),
+ 'template' => json_encode(['template_name' => $input['template_name'] ?? '', 'components' => $input['components'] ?? []]),
+ 'interactive' => json_encode(['interactive' => $input['interactive'] ?? []]),
+ default => jsonResponse(400, ['error' => "Tipo no soportado: {$type}"]),
+ };
+
+ $db = db();
+ $stmt = $db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload) VALUES (?, ?, ?, ?)");
+ $stmt->execute([$company['id'], $to, $type, $payload]);
+ jsonResponse(200, ['status' => 'queued', 'id' => (int)$db->lastInsertId()]);
+ })()],
+
+ // ─── Sincronizar empresas desde ERP (protegido) ─────────────────────────
+ ['GET', '/admin/sync-companies', fn() => DashboardController::syncCompanies()],
+
+ // ─── Listar empresas (protegido) ────────────────────────────────────────
+ ['GET', '/admin/companies', fn() => DashboardController::companies()],
+
+ // ─── Editar empresa (formulario) ───────────────────────────────────────
+ ['GET', '/admin/company/edit', fn() => DashboardController::companyEdit()],
+
+ // ─── Guardar empresa (crear/actualizar) ─────────────────────────────────
+ ['POST', '/admin/company/save', fn() => (function () {
+ SessionAuth::require();
+ $data = $_POST;
+ $id = CompanyRepository::save($data);
+ if ($id > 0) {
+ header('Location: /admin/companies?msg=' . ($data['id'] ?? 0 > 0 ? 'updated' : 'created'));
+ } else {
+ header('Location: /admin/companies?msg=error');
+ }
+ exit;
+ })()],
+
+ // ─── Eliminar empresa ──────────────────────────────────────────────────
+ ['GET', '/admin/company/delete', fn() => (function () {
+ SessionAuth::require();
+ $id = (int)($_GET['id'] ?? 0);
+ if ($id > 0 && CompanyRepository::delete($id)) {
+ header('Location: /admin/companies?msg=deleted');
+ } else {
+ header('Location: /admin/companies?msg=error');
+ }
+ exit;
+ })()],
+
+ // ─── Procesar cola outbound (protegido o cron) ─────────────────────────
+ ['GET', '/admin/process-queue', fn() => DashboardController::processQueue()],
+
+ // ─── Admin: listar pendientes de aprobación ─────────────────────────────
+ ['GET', '/admin/pending', fn() => DashboardController::pending()],
+
+ // ─── Admin: aprobar pendiente ───────────────────────────────────────────
+ ['POST', '/admin/pending-approve', fn() => (function () {
+ SessionAuth::require();
+ $user = SessionAuth::user();
+ $input = json_decode(file_get_contents('php://input'), true);
+ $id = (int)($input['id'] ?? 0);
+ if ($id === 0) jsonResponse(400, ['error' => 'ID requerido']);
+ $item = PendingApproval::approve($id, $user['name'] ?? 'Admin', $input['note'] ?? null);
+ if ($item === null) jsonResponse(404, ['error' => 'No encontrado o ya procesado']);
+ jsonResponse(200, ['status' => 'approved', 'message' => 'Respuesta aprobada y encolada']);
+ })()],
+
+ // ─── Admin: rechazar pendiente ──────────────────────────────────────────
+ ['POST', '/admin/pending-reject', fn() => (function () {
+ SessionAuth::require();
+ $user = SessionAuth::user();
+ $input = json_decode(file_get_contents('php://input'), true);
+ $id = (int)($input['id'] ?? 0);
+ if ($id === 0) jsonResponse(400, ['error' => 'ID requerido']);
+ $item = PendingApproval::reject($id, $user['name'] ?? 'Admin', $input['note'] ?? null);
+ if ($item === null) jsonResponse(404, ['error' => 'No encontrado o ya procesado']);
+ jsonResponse(200, ['status' => 'rejected', 'message' => 'Mensaje rechazado']);
+ })()],
+
+ // ─── Admin: configuración general ──────────────────────────────────────
+ ['GET', '/admin/settings', fn() => DashboardController::settings()],
+
+ ['POST', '/admin/settings/save', fn() => (function () {
+ SessionAuth::require();
+ $allowed = [
+ 'whatsapp_access_token', 'whatsapp_app_secret', 'whatsapp_verify_token',
+ 'whatsapp_business_account_id', 'whatsapp_default_phone_number_id',
+ 'ai_provider', 'openai_api_key', 'openai_model', 'ai_max_tokens', 'ai_default_prompt',
+ ];
+ $pairs = [];
+ foreach ($allowed as $k) {
+ if (isset($_POST[$k])) {
+ $pairs[$k] = trim($_POST[$k]);
+ }
+ }
+ Settings::setMany($pairs);
+ // Force re-read into $_ENV
+ foreach ($pairs as $k => $v) {
+ $_ENV[$k] = $v;
+ putenv("{$k}={$v}");
+ }
+ header('Location: /admin/settings?msg=saved');
+ exit;
+ })()],
+
+ // ─── Admin: estado de salud de los ERP ──────────────────────────────────
+ ['GET', '/admin/erp-health', fn() => (function () {
+ SessionAuth::require();
+ $result = ErpMonitor::summary();
+ jsonResponse(200, $result);
+ })()],
];
// ─── Despacho ────────────────────────────────────────────────────────────────
diff --git a/services/AiBot.php b/services/AiBot.php
new file mode 100644
index 0000000..34739c6
--- /dev/null
+++ b/services/AiBot.php
@@ -0,0 +1,155 @@
+ 'user', 'content' => $input]);
+
+ $result = self::callLlm($systemPrompt, $ctxId, $company);
+
+ if ($result === null) {
+ return null;
+ }
+
+ ConversationContext::addAiMessage($ctxId, ['role' => 'assistant', 'content' => $result['content'] ?? '']);
+
+ return [
+ 'action' => 'send',
+ 'type' => 'text',
+ 'to' => $context['from'],
+ 'payload' => json_encode(['text' => $result['content'] ?? '']),
+ ];
+ }
+
+ private static function callLlm(string $systemPrompt, int $ctxId, array $company): ?array
+ {
+ $provider = env('AI_PROVIDER', 'openai');
+ $history = ConversationContext::getAiHistory($ctxId);
+
+ return match ($provider) {
+ 'openai' => self::callOpenAI($systemPrompt, $history, $company),
+ 'mock' => self::mockResponse($history),
+ default => self::callOpenAI($systemPrompt, $history, $company),
+ };
+ }
+
+ private static function callOpenAI(string $systemPrompt, array $history, array $company): ?array
+ {
+ $apiKey = env('OPENAI_API_KEY', '');
+ if ($apiKey === '') {
+ return null;
+ }
+
+ $model = env('OPENAI_MODEL', 'gpt-4o-mini');
+
+ $messages = [
+ ['role' => 'system', 'content' => $systemPrompt],
+ ];
+
+ foreach ($history as $msg) {
+ $messages[] = [
+ 'role' => $msg['role'] ?? 'user',
+ 'content' => $msg['content'] ?? '',
+ ];
+ }
+
+ $payload = json_encode([
+ 'model' => $model,
+ 'messages' => $messages,
+ 'max_tokens' => (int)env('AI_MAX_TOKENS', '500'),
+ 'temperature' => 0.7,
+ ]);
+
+ $ch = curl_init('https://api.openai.com/v1/chat/completions');
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $payload,
+ CURLOPT_HTTPHEADER => [
+ 'Content-Type: application/json',
+ 'Authorization: Bearer ' . $apiKey,
+ ],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 30,
+ ]);
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $error = curl_error($ch);
+ curl_close($ch);
+
+ if ($httpCode !== 200 || $response === false) {
+ WpWebhook::log('ERROR', "OpenAI API error: {$error} HTTP:{$httpCode}");
+ return [
+ 'content' => 'Lo siento, tengo problemas para procesar tu mensaje. Por favor intenta de nuevo más tarde.',
+ ];
+ }
+
+ $data = json_decode($response, true);
+ $text = $data['choices'][0]['message']['content'] ?? null;
+
+ if ($text === null) {
+ return [
+ 'content' => 'No pude generar una respuesta adecuada. ¿Puedes reformular tu pregunta?',
+ ];
+ }
+
+ return ['content' => $text];
+ }
+
+ private static function mockResponse(array $history): array
+ {
+ $last = end($history);
+ $input = $last['content'] ?? '';
+
+ $responses = [
+ 'hola' => '¡Hola! ¿En qué puedo ayudarte hoy?',
+ 'gracias' => '¡De nada! Si necesitas algo más, estoy aquí.',
+ 'adios' => '¡Hasta luego! Que tengas un excelente día.',
+ 'default' => "Gracias por tu mensaje. He recibido: \"{$input}\". Un asesor se pondrá en contacto contigo pronto.",
+ ];
+
+ $normalized = mb_strtolower(trim($input));
+ $found = $responses['default'];
+
+ foreach ($responses as $keyword => $response) {
+ if (str_contains($normalized, $keyword)) {
+ $found = $response;
+ break;
+ }
+ }
+
+ return ['content' => $found];
+ }
+
+ private static function defaultPrompt(array $company): string
+ {
+ $name = $company['display_name'] ?? $company['name'] ?? 'la empresa';
+ return << 'pending',
+ 'pending_id' => $pendingId,
+ 'company_id' => (int)$company['id'],
+ 'message' => 'Nuevo mensaje pendiente de aprobación',
+ ]);
+
+ $ch = curl_init($webhookUrl);
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $payload,
+ CURLOPT_HTTPHEADER => [
+ 'Content-Type: application/json',
+ 'X-API-Key: ' . ($company['api_key'] ?? ''),
+ ],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 10,
+ ]);
+ curl_exec($ch);
+ curl_close($ch);
+ }
+
+ private static function classifyAndProcess(array $company, array $context, string $input, string $inputType): ?array
+ {
+ $botType = $company['bot_type'] ?? 'normal';
+
+ return match ($botType) {
+ 'normal' => self::runNormalBot($company, $context, $input, $inputType),
+ 'ai' => self::runAiBot($company, $context, $input),
+ 'hybrid' => self::runHybridBot($company, $context, $input, $inputType),
+ default => null,
+ };
+ }
+
+ private static function runNormalBot(array $company, array $context, string $input, string $inputType): ?array
+ {
+ if ($inputType === 'interactive' || $inputType === 'button') {
+ return NormalBot::processInteractive($company, $context, $input);
+ }
+ return NormalBot::process($company, $context, $input);
+ }
+
+ private static function runAiBot(array $company, array $context, string $input): ?array
+ {
+ return AiBot::process($company, $context, $input);
+ }
+
+ private static function runHybridBot(array $company, array $context, string $input, string $inputType): ?array
+ {
+ $response = self::runNormalBot($company, $context, $input, $inputType);
+
+ if ($response !== null) {
+ return $response;
+ }
+
+ self::log("NormalBot no manejó '{$input}', escalando a IA");
+ return self::runAiBot($company, $context, $input);
+ }
+
+ private static function shouldSkipBot(array $company, array $context): bool
+ {
+ if (!(int)($company['is_active'] ?? 0)) {
+ return true;
+ }
+
+ $config = self::getConfig($company);
+ $ignorePrefixes = $config['ignore_prefixes'] ?? [];
+
+ foreach ($ignorePrefixes as $prefix) {
+ if (str_starts_with($context['from'] ?? '', $prefix)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static function enqueueResponse(array $response, array $company): void
+ {
+ $to = $response['to'] ?? '';
+ $type = $response['type'] ?? 'text';
+ $payload = $response['payload'] ?? '';
+
+ if ($to === '' || $payload === '') {
+ return;
+ }
+
+ try {
+ $stmt = db()->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload) VALUES (?, ?, ?, ?)");
+ $stmt->execute([$company['id'], $to, $type, $payload]);
+ self::log("Bot encoló respuesta {$type} para {$to}");
+ } catch (\PDOException $e) {
+ self::log('ERROR encolando respuesta: ' . $e->getMessage());
+ }
+ }
+
+ private static function getConfig(array $company): array
+ {
+ $json = $company['config_json'] ?? '';
+ if ($json === '') return [];
+ $config = json_decode($json, true);
+ return is_array($config) ? $config : [];
+ }
+
+ private static function log(string $message): void
+ {
+ $dir = dirname(__DIR__) . '/storage/logs';
+ if (!is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+ $file = $dir . '/bot-' . date('Y-m-d') . '.log';
+ $line = '[' . date('Y-m-d H:i:s') . '] [BOT] ' . $message . PHP_EOL;
+ file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
+
+ if (env('APP_ENV', 'production') === 'local') {
+ error_log($line);
+ }
+ }
+}
diff --git a/services/CompanyApiClient.php b/services/CompanyApiClient.php
new file mode 100644
index 0000000..fd2a3cf
--- /dev/null
+++ b/services/CompanyApiClient.php
@@ -0,0 +1,70 @@
+ (int)$company['id'],
+ 'from' => $messageData['from'] ?? '',
+ 'name' => $messageData['name'] ?? '',
+ 'message_id' => $messageData['message_id'] ?? '',
+ 'type' => $messageData['type'] ?? 'unknown',
+ 'content' => $messageData['content'] ?? '',
+ 'media_id' => $messageData['media_id'] ?? null,
+ 'timestamp' => $messageData['timestamp'] ?? time(),
+ 'phone_number_id' => $messageData['phone_number_id'] ?? '',
+ 'display_phone' => $messageData['display_phone'] ?? '',
+ 'raw_payload' => $messageData['raw_payload'] ?? null,
+ ];
+
+ return self::post($endpoint, $company['api_key'] ?? '', $payload);
+ }
+
+ public static function forwardStatus(array $company, array $statusData): array
+ {
+ $endpoint = rtrim($company['api_base_url'], '/') . '/webhook/status';
+
+ $payload = [
+ 'company_id' => (int)$company['id'],
+ 'message_id' => $statusData['message_id'] ?? '',
+ 'status' => $statusData['status'] ?? '',
+ 'recipient' => $statusData['recipient'] ?? '',
+ 'timestamp' => $statusData['timestamp'] ?? time(),
+ 'errors' => $statusData['errors'] ?? null,
+ ];
+
+ return self::post($endpoint, $company['api_key'] ?? '', $payload);
+ }
+
+ private static function post(string $url, string $apiKey, array $payload): array
+ {
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => json_encode($payload),
+ CURLOPT_HTTPHEADER => [
+ 'Content-Type: application/json',
+ 'X-API-Key: ' . $apiKey,
+ 'User-Agent: bot-palmas360/1.0',
+ ],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 15,
+ ]);
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $error = curl_error($ch);
+ curl_close($ch);
+
+ return [
+ 'http_code' => $httpCode,
+ 'response' => $response !== false ? json_decode($response, true) : null,
+ 'error' => $error ?: null,
+ 'success' => $httpCode >= 200 && $httpCode < 300,
+ ];
+ }
+}
diff --git a/services/CompanyRepository.php b/services/CompanyRepository.php
new file mode 100644
index 0000000..0fc2a3a
--- /dev/null
+++ b/services/CompanyRepository.php
@@ -0,0 +1,82 @@
+prepare("SELECT * FROM companies WHERE phone_number_id = ? AND is_active = 1 LIMIT 1");
+ $stmt->execute([$phoneNumberId]);
+ $row = $stmt->fetch();
+ return $row ?: null;
+ }
+
+ public static function findById(int $id): ?array
+ {
+ $stmt = db()->prepare("SELECT * FROM companies WHERE id = ? LIMIT 1");
+ $stmt->execute([$id]);
+ $row = $stmt->fetch();
+ return $row ?: null;
+ }
+
+ public static function findByApiKey(string $apiKey): ?array
+ {
+ $stmt = db()->prepare("SELECT * FROM companies WHERE api_key = ? AND is_active = 1 LIMIT 1");
+ $stmt->execute([$apiKey]);
+ $row = $stmt->fetch();
+ return $row ?: null;
+ }
+
+ public static function findAll(bool $includeInactive = false): array
+ {
+ $where = $includeInactive ? '1=1' : 'is_active = 1';
+ return db()->query("SELECT * FROM companies WHERE {$where} ORDER BY name")->fetchAll();
+ }
+
+ public static function save(array $data): int
+ {
+ $db = db();
+ $id = (int)($data['id'] ?? 0);
+
+ $fields = ['name', 'display_name', 'phone_number_id', 'display_phone', 'api_base_url', 'api_key', 'bot_type', 'requires_approval', 'is_active', 'config_json'];
+ $params = [];
+ $sets = [];
+ foreach ($fields as $f) {
+ if (array_key_exists($f, $data)) {
+ $sets[] = "{$f} = ?";
+ $params[] = is_array($data[$f]) || is_object($data[$f]) ? json_encode($data[$f]) : $data[$f];
+ }
+ }
+
+ if ($id > 0) {
+ $params[] = $id;
+ $stmt = $db->prepare("UPDATE companies SET " . implode(', ', $sets) . " WHERE id = ?");
+ $stmt->execute($params);
+ return $id;
+ } else {
+ // Ensure required fields
+ $required = ['name', 'phone_number_id', 'api_base_url'];
+ $insertData = [];
+ foreach ($required as $r) {
+ $insertData[$r] = $data[$r] ?? '';
+ }
+ foreach ($fields as $f) {
+ if (array_key_exists($f, $data)) {
+ $insertData[$f] = $data[$f];
+ }
+ }
+ $cols = implode(', ', array_keys($insertData));
+ $vals = implode(', ', array_fill(0, count($insertData), '?'));
+ $stmt = $db->prepare("INSERT INTO companies ({$cols}) VALUES ({$vals})");
+ $stmt->execute(array_values($insertData));
+ return (int)$db->lastInsertId();
+ }
+ }
+
+ public static function delete(int $id): bool
+ {
+ $stmt = db()->prepare("DELETE FROM companies WHERE id = ?");
+ $stmt->execute([$id]);
+ return $stmt->rowCount() > 0;
+ }
+}
diff --git a/services/ConversationContext.php b/services/ConversationContext.php
new file mode 100644
index 0000000..ce5d380
--- /dev/null
+++ b/services/ConversationContext.php
@@ -0,0 +1,81 @@
+prepare("SELECT * FROM bot_context WHERE company_id = ? AND phone_number = ? LIMIT 1");
+ $stmt->execute([$companyId, $phoneNumber]);
+ $ctx = $stmt->fetch();
+
+ if ($ctx) {
+ return $ctx;
+ }
+
+ $stmt = db()->prepare("INSERT INTO bot_context (company_id, phone_number, bot_type, ai_history, metadata) VALUES (?, ?, ?, '[]', '{}')");
+ $stmt->execute([$companyId, $phoneNumber, $botType]);
+
+ return [
+ 'id' => (int)db()->lastInsertId(),
+ 'company_id' => $companyId,
+ 'phone_number' => $phoneNumber,
+ 'bot_type' => $botType,
+ 'current_node' => null,
+ 'ai_history' => '[]',
+ 'metadata' => '{}',
+ ];
+ }
+
+ public static function updateNode(int $id, ?string $node): void
+ {
+ $stmt = db()->prepare("UPDATE bot_context SET current_node = ?, updated_at = NOW() WHERE id = ?");
+ $stmt->execute([$node, $id]);
+ }
+
+ public static function addAiMessage(int $id, array $message): void
+ {
+ $stmt = db()->prepare("SELECT ai_history FROM bot_context WHERE id = ? LIMIT 1");
+ $stmt->execute([$id]);
+ $row = $stmt->fetch();
+
+ $history = $row ? json_decode($row['ai_history'], true) : [];
+
+ $history[] = $message;
+
+ if (count($history) > 50) {
+ array_shift($history);
+ }
+
+ $stmt = db()->prepare("UPDATE bot_context SET ai_history = ?, updated_at = NOW() WHERE id = ?");
+ $stmt->execute([json_encode($history), $id]);
+ }
+
+ public static function getAiHistory(int $id): array
+ {
+ $stmt = db()->prepare("SELECT ai_history FROM bot_context WHERE id = ? LIMIT 1");
+ $stmt->execute([$id]);
+ $row = $stmt->fetch();
+ return $row ? json_decode($row['ai_history'], true) ?? [] : [];
+ }
+
+ public static function updateMetadata(int $id, array $metadata): void
+ {
+ $stmt = db()->prepare("UPDATE bot_context SET metadata = ?, updated_at = NOW() WHERE id = ?");
+ $stmt->execute([json_encode($metadata), $id]);
+ }
+
+ public static function getMetadata(int $id): array
+ {
+ $stmt = db()->prepare("SELECT metadata FROM bot_context WHERE id = ? LIMIT 1");
+ $stmt->execute([$id]);
+ $row = $stmt->fetch();
+ return $row ? json_decode($row['metadata'], true) ?? [] : [];
+ }
+
+ public static function reset(int $id): void
+ {
+ $stmt = db()->prepare("UPDATE bot_context SET current_node = NULL, ai_history = '[]', metadata = '{}', updated_at = NOW() WHERE id = ?");
+ $stmt->execute([$id]);
+ }
+}
diff --git a/services/ErpMonitor.php b/services/ErpMonitor.php
new file mode 100644
index 0000000..ab0c44e
--- /dev/null
+++ b/services/ErpMonitor.php
@@ -0,0 +1,120 @@
+ (int)$company['id'],
+ 'company_name' => $company['name'] ?? '',
+ 'status' => 'unknown',
+ 'latency_ms' => null,
+ 'error' => 'Sin URL configurada',
+ 'last_check' => date('Y-m-d H:i:s'),
+ ];
+ }
+
+ $start = microtime(true);
+
+ $ch = curl_init($healthUrl);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 10,
+ CURLOPT_CONNECTTIMEOUT => 5,
+ CURLOPT_HTTPHEADER => [
+ 'X-API-Key: ' . $apiKey,
+ 'User-Agent: bot-palmas360-monitor/1.0',
+ ],
+ ]);
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $error = curl_error($ch);
+ curl_close($ch);
+
+ $latency = (int)((microtime(true) - $start) * 1000);
+
+ if ($error !== '') {
+ return [
+ 'company_id' => (int)$company['id'],
+ 'company_name' => $company['name'] ?? '',
+ 'status' => 'down',
+ 'latency_ms' => $latency,
+ 'error' => $error,
+ 'last_check' => date('Y-m-d H:i:s'),
+ ];
+ }
+
+ if ($httpCode >= 200 && $httpCode < 400) {
+ return [
+ 'company_id' => (int)$company['id'],
+ 'company_name' => $company['name'] ?? '',
+ 'status' => 'up',
+ 'latency_ms' => $latency,
+ 'error' => null,
+ 'last_check' => date('Y-m-d H:i:s'),
+ ];
+ }
+
+ return [
+ 'company_id' => (int)$company['id'],
+ 'company_name' => $company['name'] ?? '',
+ 'status' => 'degraded',
+ 'latency_ms' => $latency,
+ 'error' => "HTTP {$httpCode}",
+ 'last_check' => date('Y-m-d H:i:s'),
+ ];
+ }
+
+ public static function checkById(int $companyId): ?array
+ {
+ $company = CompanyRepository::findById($companyId);
+ if ($company === null) return null;
+ return self::check($company);
+ }
+
+ public static function summary(): array
+ {
+ $results = self::checkAll();
+ $up = 0;
+ $down = 0;
+ $degraded = 0;
+ $unknown = 0;
+
+ foreach ($results as $r) {
+ match ($r['status']) {
+ 'up' => $up++,
+ 'down' => $down++,
+ 'degraded' => $degraded++,
+ default => $unknown++,
+ };
+ }
+
+ return [
+ 'total' => count($results),
+ 'up' => $up,
+ 'down' => $down,
+ 'degraded' => $degraded,
+ 'unknown' => $unknown,
+ 'results' => $results,
+ ];
+ }
+}
diff --git a/services/ErpSync.php b/services/ErpSync.php
new file mode 100644
index 0000000..a5ca468
--- /dev/null
+++ b/services/ErpSync.php
@@ -0,0 +1,86 @@
+ 'ERP_SYNC_API_URL no configurado'];
+ }
+
+ $httpHeader = ['Accept: application/json'];
+ if ($apiKey !== '') {
+ $httpHeader[] = 'Authorization: Bearer ' . $apiKey;
+ }
+
+ $ch = curl_init($apiUrl);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPHEADER => $httpHeader,
+ CURLOPT_TIMEOUT => 30,
+ ]);
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($httpCode !== 200) {
+ return ['error' => "HTTP {$httpCode}", 'response' => mb_substr((string)$response, 0, 1000)];
+ }
+
+ $companies = json_decode($response, true);
+ if (!is_array($companies)) {
+ return ['error' => 'JSON inválido del ERP'];
+ }
+
+ $synced = 0;
+ $errors = [];
+
+ foreach ($companies as $company) {
+ try {
+ self::upsert($company);
+ $synced++;
+ } catch (\Exception $e) {
+ $errors[] = $e->getMessage();
+ }
+ }
+
+ return ['synced' => $synced, 'errors' => $errors];
+ }
+
+ private static function upsert(array $data): void
+ {
+ $stmt = db()->prepare("
+ INSERT INTO companies
+ (name, display_name, phone_number_id, display_phone, api_base_url, api_key, bot_type, requires_approval, is_active, config_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ name = VALUES(name),
+ display_name = VALUES(display_name),
+ api_base_url = VALUES(api_base_url),
+ api_key = VALUES(api_key),
+ bot_type = VALUES(bot_type),
+ requires_approval = VALUES(requires_approval),
+ is_active = VALUES(is_active),
+ config_json = VALUES(config_json),
+ updated_at = NOW()
+ ");
+
+ $stmt->execute([
+ $data['name'] ?? '',
+ $data['display_name'] ?? $data['name'] ?? '',
+ $data['phone_number_id'] ?? '',
+ $data['display_phone'] ?? '',
+ $data['api_base_url'] ?? '',
+ $data['api_key'] ?? '',
+ $data['bot_type'] ?? 'normal',
+ (int)($data['requires_approval'] ?? 0),
+ (int)($data['is_active'] ?? 1),
+ isset($data['config_json']) ? (is_string($data['config_json']) ? $data['config_json'] : json_encode($data['config_json'])) : null,
+ ]);
+ }
+}
diff --git a/services/NormalBot.php b/services/NormalBot.php
new file mode 100644
index 0000000..b133e4f
--- /dev/null
+++ b/services/NormalBot.php
@@ -0,0 +1,240 @@
+ $action) {
+ if ($normalized === self::normalize($keyword)) {
+ $matchedCommand = $action;
+ break;
+ }
+ }
+
+ if ($matchedCommand !== null) {
+ ConversationContext::updateNode($ctxId, $matchedCommand);
+
+ if (isset($flows[$matchedCommand])) {
+ return self::handleFlow($flows[$matchedCommand], $context, $company, $ctxId);
+ }
+
+ if (isset($menus[$matchedCommand])) {
+ return self::buildMenuResponse($menus[$matchedCommand], $context['from'], $company);
+ }
+
+ return null;
+ }
+
+ $greeting = $config['greeting'] ?? null;
+ if ($greeting !== null && $currentNode === null) {
+ ConversationContext::updateNode($ctxId, 'greeting');
+
+ if (isset($flows['greeting'])) {
+ return self::handleFlow($flows['greeting'], $context, $company, $ctxId);
+ }
+
+ return self::sendText($greeting, $context['from'], $company);
+ }
+
+ $fallback = $config['fallback'] ?? null;
+ if ($fallback !== null) {
+ return self::sendText($fallback, $context['from'], $company);
+ }
+
+ return null;
+ }
+
+ private static function handleFlow(array $flow, array $context, array $company, int $ctxId): ?array
+ {
+ $type = $flow['type'] ?? 'text';
+
+ return match ($type) {
+ 'text' => self::sendText($flow['message'] ?? '', $context['from'], $company),
+ 'image' => self::sendImage($flow['media_id'] ?? '', $flow['caption'] ?? null, $context['from'], $company),
+ 'menu' => self::buildMenuResponse($flow['menu'] ?? [], $context['from'], $company),
+ 'function' => self::executeFunction($flow['function'] ?? '', $flow['params'] ?? [], $context, $company, $ctxId),
+ default => null,
+ };
+ }
+
+ private static function executeFunction(string $function, array $params, array $context, array $company, int $ctxId): ?array
+ {
+ return match ($function) {
+ 'reset' => (function () use ($ctxId, $context, $company) {
+ ConversationContext::reset($ctxId);
+ return self::sendText('¿En qué más puedo ayudarte?', $context['from'], $company);
+ })(),
+ 'forward_to_ai' => null,
+ default => null,
+ };
+ }
+
+ private static function buildMenuResponse(array $menu, string $to, array $company): array
+ {
+ $menuType = $menu['type'] ?? 'list';
+
+ if ($menuType === 'list') {
+ $interactive = [
+ 'type' => 'list',
+ 'header' => [
+ 'type' => 'text',
+ 'text' => mb_substr($menu['header'] ?? 'Menú', 0, 60),
+ ],
+ 'body' => [
+ 'text' => mb_substr($menu['body'] ?? 'Selecciona una opción:', 0, 1024),
+ ],
+ 'footer' => [
+ 'text' => mb_substr($menu['footer'] ?? $company['display_name'] ?? '', 0, 60),
+ ],
+ 'action' => [
+ 'button' => mb_substr($menu['button'] ?? 'Ver opciones', 0, 20),
+ 'sections' => [],
+ ],
+ ];
+
+ foreach ($menu['sections'] ?? [] as $section) {
+ $rows = [];
+ foreach ($section['rows'] ?? [] as $row) {
+ $rows[] = [
+ 'id' => mb_substr($row['id'] ?? '', 0, 200),
+ 'title' => mb_substr($row['title'] ?? '', 0, 24),
+ 'description' => isset($row['description']) ? mb_substr($row['description'], 0, 72) : null,
+ ];
+ }
+ $interactive['action']['sections'][] = [
+ 'title' => mb_substr($section['title'] ?? '', 0, 24),
+ 'rows' => $rows,
+ ];
+ }
+
+ return self::enqueueInteractive($to, $interactive, $company);
+ }
+
+ if ($menuType === 'buttons') {
+ $buttons = [];
+ foreach ($menu['buttons'] ?? [] as $btn) {
+ $buttons[] = [
+ 'type' => 'reply',
+ 'reply' => [
+ 'id' => mb_substr($btn['id'] ?? '', 0, 256),
+ 'title' => mb_substr($btn['title'] ?? '', 0, 20),
+ ],
+ ];
+ }
+
+ $interactive = [
+ 'type' => 'button',
+ 'body' => [
+ 'text' => mb_substr($menu['body'] ?? 'Selecciona:', 0, 1024),
+ ],
+ 'action' => ['buttons' => $buttons],
+ ];
+
+ return self::enqueueInteractive($to, $interactive, $company);
+ }
+
+ return null;
+ }
+
+ private static function sendText(string $text, string $to, array $company): array
+ {
+ return [
+ 'action' => 'send',
+ 'type' => 'text',
+ 'to' => $to,
+ 'payload' => json_encode(['text' => $text]),
+ ];
+ }
+
+ private static function sendImage(string $mediaId, ?string $caption, string $to, array $company): array
+ {
+ return [
+ 'action' => 'send',
+ 'type' => 'image',
+ 'to' => $to,
+ 'payload' => json_encode(['media_id' => $mediaId, 'caption' => $caption]),
+ ];
+ }
+
+ private static function enqueueInteractive(string $to, array $interactive, array $company): array
+ {
+ return [
+ 'action' => 'send',
+ 'type' => 'interactive',
+ 'to' => $to,
+ 'payload' => json_encode(['interactive' => $interactive]),
+ ];
+ }
+
+ private static function getConfig(array $company): array
+ {
+ $json = $company['config_json'] ?? '';
+ if ($json === '') {
+ return [];
+ }
+ $config = json_decode($json, true);
+ return is_array($config) ? $config : [];
+ }
+
+ private static function normalize(string $input): string
+ {
+ $input = mb_strtolower(trim($input));
+ $input = str_replace(['á', 'é', 'í', 'ó', 'ú', 'ü', 'ñ'], ['a', 'e', 'i', 'o', 'u', 'u', 'n'], $input);
+ return preg_replace('/[^a-z0-9\s]/', '', $input);
+ }
+
+ public static function processInteractive(array $company, array $context, string $input): ?array
+ {
+ $config = self::getConfig($company);
+ $flows = $config['flows'] ?? [];
+
+ $botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
+ $ctxId = (int)$botCtx['id'];
+
+ foreach ($flows as $flowId => $flow) {
+ if (($flow['type'] ?? '') === 'menu') {
+ $menu = $flow['menu'] ?? [];
+ foreach ($menu['sections'] ?? [] as $section) {
+ foreach ($section['rows'] ?? [] as $row) {
+ if (($row['id'] ?? '') === $input) {
+ ConversationContext::updateNode($ctxId, $row['id']);
+ if (isset($flows[$row['id']])) {
+ return self::handleFlow($flows[$row['id']], $context, $company, $ctxId);
+ }
+ return null;
+ }
+ }
+ }
+ foreach ($menu['buttons'] ?? [] as $btn) {
+ if (($btn['id'] ?? '') === $input) {
+ ConversationContext::updateNode($ctxId, $btn['id']);
+ if (isset($flows[$btn['id']])) {
+ return self::handleFlow($flows[$btn['id']], $context, $company, $ctxId);
+ }
+ return null;
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/services/OutboundWorker.php b/services/OutboundWorker.php
new file mode 100644
index 0000000..d33db2b
--- /dev/null
+++ b/services/OutboundWorker.php
@@ -0,0 +1,96 @@
+prepare("UPDATE outbound_queue SET status = 'sent', wam_id = ?, updated_at = NOW() WHERE id = ?");
+ $stmt->execute([$result['wam_id'], $item['id']]);
+ $processed++;
+ } else {
+ $attempts = (int)$item['attempts'] + 1;
+ $max = (int)$item['max_attempts'];
+
+ if ($attempts >= $max) {
+ $stmt = db()->prepare("UPDATE outbound_queue SET status = 'failed', attempts = ?, last_error = ?, updated_at = NOW() WHERE id = ?");
+ $stmt->execute([$attempts, $result['error'], $item['id']]);
+ } else {
+ $stmt = db()->prepare("UPDATE outbound_queue SET status = 'queued', attempts = ?, last_error = ?, updated_at = NOW() WHERE id = ?");
+ $stmt->execute([$attempts, $result['error'], $item['id']]);
+ }
+
+ $errors[] = "msg#{$item['id']}: {$result['error']}";
+ }
+ } catch (\Exception $e) {
+ $errors[] = "msg#{$item['id']}: " . $e->getMessage();
+ }
+ }
+
+ return ['processed' => $processed, 'errors' => $errors];
+ }
+
+ private static function dequeue(): array
+ {
+ $db = db();
+ $db->beginTransaction();
+
+ try {
+ $stmt = $db->prepare("
+ SELECT q.*, c.phone_number_id
+ FROM outbound_queue q
+ JOIN companies c ON c.id = q.company_id
+ WHERE q.status = 'queued' AND c.is_active = 1
+ ORDER BY q.id ASC
+ LIMIT ?
+ FOR UPDATE SKIP LOCKED
+ ");
+ $stmt->execute([self::BATCH_SIZE]);
+ $items = $stmt->fetchAll();
+
+ if (!empty($items)) {
+ $ids = array_column($items, 'id');
+ $placeholders = implode(',', array_fill(0, count($ids), '?'));
+ $db->prepare("UPDATE outbound_queue SET status = 'sending', updated_at = NOW() WHERE id IN ({$placeholders})")->execute($ids);
+ }
+
+ $db->commit();
+ return $items;
+ } catch (\Exception $e) {
+ $db->rollBack();
+ throw $e;
+ }
+ }
+
+ private static function process(array $item): array
+ {
+ $payload = json_decode($item['payload'], true);
+ if (!is_array($payload)) {
+ return ['success' => false, 'error' => 'Payload inválido'];
+ }
+
+ $phoneNumberId = $item['phone_number_id'];
+ $type = $item['message_type'];
+ $to = $item['to_number'];
+
+ return match ($type) {
+ 'text' => WhatsAppSender::sendText($to, $payload['text'] ?? '', $phoneNumberId),
+ 'image' => WhatsAppSender::sendImage($to, $payload['media_id'] ?? $payload['url'] ?? '', $phoneNumberId, $payload['caption'] ?? null),
+ 'template'=> WhatsAppSender::sendTemplate($to, $payload['template_name'] ?? '', $phoneNumberId, $payload['components'] ?? []),
+ 'interactive' => WhatsAppSender::sendInteractive($to, $payload['interactive'] ?? [], $phoneNumberId),
+ default => ['success' => false, 'error' => "Tipo no soportado: {$type}"],
+ };
+ }
+}
diff --git a/services/PendingApproval.php b/services/PendingApproval.php
new file mode 100644
index 0000000..bf93ce4
--- /dev/null
+++ b/services/PendingApproval.php
@@ -0,0 +1,113 @@
+prepare("
+ INSERT INTO pending_approval (company_id, phone_number, contact_name, incoming_msg, bot_response, bot_type, context_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ");
+ $stmt->execute([
+ $companyId,
+ $phone,
+ $name,
+ $incomingMsg,
+ $botResponse !== null ? json_encode($botResponse) : null,
+ $botType,
+ json_encode($context),
+ ]);
+
+ return [
+ 'id' => (int)db()->lastInsertId(),
+ 'status' => 'pending',
+ ];
+ }
+
+ public static function approve(int $id, ?string $reviewedBy = null, ?string $note = null): ?array
+ {
+ $stmt = db()->prepare("SELECT * FROM pending_approval WHERE id = ? AND status = 'pending' LIMIT 1");
+ $stmt->execute([$id]);
+ $item = $stmt->fetch();
+
+ if (!$item) {
+ return null;
+ }
+
+ $stmt = db()->prepare("UPDATE pending_approval SET status = 'approved', reviewed_by = ?, review_note = ?, reviewed_at = NOW() WHERE id = ?");
+ $stmt->execute([$reviewedBy, $note, $id]);
+
+ $botResponse = $item['bot_response'] ? json_decode($item['bot_response'], true) : null;
+
+ if ($botResponse !== null) {
+ $company = CompanyRepository::findById((int)$item['company_id']);
+ if ($company !== null) {
+ $stmt = db()->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload) VALUES (?, ?, ?, ?)");
+ $stmt->execute([
+ $company['id'],
+ $item['phone_number'],
+ $botResponse['type'] ?? 'text',
+ $botResponse['payload'] ?? json_encode(['text' => '']),
+ ]);
+ }
+ }
+
+ return $item;
+ }
+
+ public static function reject(int $id, ?string $reviewedBy = null, ?string $note = null): ?array
+ {
+ $stmt = db()->prepare("SELECT * FROM pending_approval WHERE id = ? AND status = 'pending' LIMIT 1");
+ $stmt->execute([$id]);
+ $item = $stmt->fetch();
+
+ if (!$item) {
+ return null;
+ }
+
+ $stmt = db()->prepare("UPDATE pending_approval SET status = 'rejected', reviewed_by = ?, review_note = ?, reviewed_at = NOW() WHERE id = ?");
+ $stmt->execute([$reviewedBy, $note, $id]);
+
+ return $item;
+ }
+
+ public static function findByCompany(int $companyId, string $status = 'pending', int $limit = 50): array
+ {
+ $stmt = db()->prepare("
+ SELECT pa.*, c.name AS company_name, c.display_name AS company_display
+ FROM pending_approval pa
+ JOIN companies c ON c.id = pa.company_id
+ WHERE pa.company_id = ? AND pa.status = ?
+ ORDER BY pa.created_at DESC
+ LIMIT ?
+ ");
+ $stmt->execute([$companyId, $status, $limit]);
+ return $stmt->fetchAll();
+ }
+
+ public static function findAll(string $status = 'pending', int $limit = 50): array
+ {
+ $stmt = db()->prepare("
+ SELECT pa.*, c.name AS company_name, c.display_name AS company_display
+ FROM pending_approval pa
+ JOIN companies c ON c.id = pa.company_id
+ WHERE pa.status = ?
+ ORDER BY pa.created_at DESC
+ LIMIT ?
+ ");
+ $stmt->execute([$status, $limit]);
+ return $stmt->fetchAll();
+ }
+
+ public static function countPending(?int $companyId = null): int
+ {
+ if ($companyId !== null) {
+ $stmt = db()->prepare("SELECT COUNT(*) FROM pending_approval WHERE company_id = ? AND status = 'pending'");
+ $stmt->execute([$companyId]);
+ } else {
+ $stmt = db()->query("SELECT COUNT(*) FROM pending_approval WHERE status = 'pending'");
+ }
+ return (int)$stmt->fetchColumn();
+ }
+}
diff --git a/services/Settings.php b/services/Settings.php
new file mode 100644
index 0000000..fe507ae
--- /dev/null
+++ b/services/Settings.php
@@ -0,0 +1,40 @@
+query("SELECT `key`, `value` FROM settings")->fetchAll();
+ $map = [];
+ foreach ($rows as $r) $map[$r['key']] = $r['value'];
+ self::$cache = $map;
+ return $map;
+ }
+
+ public static function get(string $key, string $default = ''): string
+ {
+ $all = self::all();
+ return $all[$key] ?? $default;
+ }
+
+ public static function set(string $key, string $value): void
+ {
+ $stmt = db()->prepare("INSERT INTO settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = ?");
+ $stmt->execute([$key, $value, $value]);
+ self::$cache = null;
+ }
+
+ public static function setMany(array $pairs): void
+ {
+ $db = db();
+ $stmt = $db->prepare("INSERT INTO settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = ?");
+ foreach ($pairs as $key => $value) {
+ $stmt->execute([$key, (string)$value, (string)$value]);
+ }
+ self::$cache = null;
+ }
+}
diff --git a/services/WhatsAppSender.php b/services/WhatsAppSender.php
new file mode 100644
index 0000000..c6d8129
--- /dev/null
+++ b/services/WhatsAppSender.php
@@ -0,0 +1,106 @@
+ 'whatsapp',
+ 'recipient_type' => 'individual',
+ 'to' => $to,
+ 'type' => 'text',
+ 'text' => ['body' => $text],
+ ]);
+ }
+
+ public static function sendTemplate(string $to, string $templateName, string $phoneNumberId, array $components = []): array
+ {
+ $payload = [
+ 'messaging_product' => 'whatsapp',
+ 'recipient_type' => 'individual',
+ 'to' => $to,
+ 'type' => 'template',
+ 'template' => [
+ 'name' => $templateName,
+ 'language' => ['code' => 'es'],
+ ],
+ ];
+
+ if (!empty($components)) {
+ $payload['template']['components'] = $components;
+ }
+
+ return self::callApi($phoneNumberId, $payload);
+ }
+
+ public static function sendImage(string $to, string $mediaIdOrUrl, string $phoneNumberId, ?string $caption = null): array
+ {
+ $payload = [
+ 'messaging_product' => 'whatsapp',
+ 'recipient_type' => 'individual',
+ 'to' => $to,
+ 'type' => 'image',
+ 'image' => [
+ (str_starts_with($mediaIdOrUrl, 'http') ? 'link' : 'id') => $mediaIdOrUrl,
+ ],
+ ];
+
+ if ($caption !== null) {
+ $payload['image']['caption'] = $caption;
+ }
+
+ return self::callApi($phoneNumberId, $payload);
+ }
+
+ public static function sendInteractive(string $to, array $interactive, string $phoneNumberId): array
+ {
+ return self::callApi($phoneNumberId, [
+ 'messaging_product' => 'whatsapp',
+ 'recipient_type' => 'individual',
+ 'to' => $to,
+ 'type' => 'interactive',
+ 'interactive' => $interactive,
+ ]);
+ }
+
+ private static function callApi(string $phoneNumberId, array $payload): array
+ {
+ $url = self::BASE_URL . '/' . self::API_VERSION . '/' . $phoneNumberId . '/messages';
+ $token = env('WHATSAPP_ACCESS_TOKEN', '');
+
+ if ($token === '') {
+ return ['success' => false, 'error' => 'WHATSAPP_ACCESS_TOKEN no configurado'];
+ }
+
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => json_encode($payload),
+ CURLOPT_HTTPHEADER => [
+ 'Authorization: Bearer ' . $token,
+ 'Content-Type: application/json',
+ ],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 15,
+ ]);
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $error = curl_error($ch);
+ curl_close($ch);
+
+ $decoded = $response ? json_decode($response, true) : null;
+
+ return [
+ 'success' => $httpCode >= 200 && $httpCode < 300,
+ 'http_code' => $httpCode,
+ 'response' => $decoded ?? $response,
+ 'error' => $error ?: ($decoded['error']['message'] ?? null),
+ 'wam_id' => $decoded['messages'][0]['id'] ?? null,
+ ];
+ }
+}
diff --git a/setup/migrate.php b/setup/migrate.php
index b842d20..7c8c5e9 100644
--- a/setup/migrate.php
+++ b/setup/migrate.php
@@ -79,6 +79,134 @@ $db->exec("
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
+// ─── Tabla de empresas (multi-tenant) ─────────────────────────────────────────
+
+$db->exec("
+ CREATE TABLE IF NOT EXISTS companies (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(150) NOT NULL,
+ display_name VARCHAR(150),
+ phone_number_id VARCHAR(50) NOT NULL UNIQUE,
+ display_phone VARCHAR(30),
+ api_base_url VARCHAR(500) NOT NULL,
+ api_key VARCHAR(255),
+ bot_type ENUM('ai','normal','hybrid') DEFAULT 'normal',
+ requires_approval TINYINT(1) DEFAULT 0,
+ is_active TINYINT(1) DEFAULT 1,
+ config_json TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_phone_number_id (phone_number_id),
+ INDEX idx_is_active (is_active)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+");
+
+// ─── Cola de mensajes salientes ──────────────────────────────────────────────
+
+$db->exec("
+ CREATE TABLE IF NOT EXISTS outbound_queue (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ company_id INT NOT NULL,
+ to_number VARCHAR(30) NOT NULL,
+ message_type VARCHAR(30) NOT NULL DEFAULT 'text',
+ payload TEXT NOT NULL,
+ status ENUM('queued','sent','delivered','failed') DEFAULT 'queued',
+ attempts TINYINT DEFAULT 0,
+ max_attempts TINYINT DEFAULT 5,
+ last_error TEXT,
+ wam_id VARCHAR(100),
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_company_status (company_id, status),
+ INDEX idx_status (status),
+ FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+");
+
+// ─── Contexto de conversación del bot ─────────────────────────────────────────
+
+$db->exec("
+ CREATE TABLE IF NOT EXISTS bot_context (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ company_id INT NOT NULL,
+ phone_number VARCHAR(30) NOT NULL,
+ bot_type ENUM('normal','ai') DEFAULT 'normal',
+ current_node VARCHAR(100),
+ ai_history LONGTEXT,
+ metadata TEXT,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY idx_bot_context (company_id, phone_number),
+ FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+");
+
+// ─── Cola de aprobación — mensajes que requieren revisión humana ──────────────
+
+$db->exec("
+ CREATE TABLE IF NOT EXISTS pending_approval (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ company_id INT NOT NULL,
+ phone_number VARCHAR(30) NOT NULL,
+ contact_name VARCHAR(150),
+ incoming_msg TEXT NOT NULL,
+ bot_response TEXT,
+ bot_type VARCHAR(30),
+ context_json TEXT,
+ status ENUM('pending','approved','rejected') DEFAULT 'pending',
+ reviewed_by VARCHAR(100),
+ review_note TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ reviewed_at DATETIME,
+ INDEX idx_company_status (company_id, status),
+ INDEX idx_status (status),
+ FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+");
+
+// ─── Agregar company_id a tablas existentes ────────────────────────────────────
+
+try {
+ $db->exec("ALTER TABLE conversations ADD COLUMN company_id INT DEFAULT NULL AFTER id, ADD INDEX idx_conv_company (company_id)");
+} catch (\PDOException $e) {
+ // Columna ya existe
+}
+
+try {
+ $db->exec("ALTER TABLE webhook_logs ADD COLUMN company_id INT DEFAULT NULL AFTER id, ADD INDEX idx_wh_company (company_id)");
+} catch (\PDOException $e) {}
+
+try {
+ $db->exec("ALTER TABLE notifications ADD COLUMN company_id INT DEFAULT NULL AFTER id, ADD INDEX idx_notif_company (company_id)");
+} catch (\PDOException $e) {}
+
+// ─── Tabla de configuración general ────────────────────────────────────────────
+
+$db->exec("
+ CREATE TABLE IF NOT EXISTS settings (
+ `key` VARCHAR(100) NOT NULL PRIMARY KEY,
+ `value` TEXT DEFAULT NULL,
+ `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+");
+
+// Seed defaults si están vacíos
+$seedSettings = [
+ 'whatsapp_access_token' => '',
+ 'whatsapp_app_secret' => '',
+ 'whatsapp_verify_token' => '',
+ 'whatsapp_business_account_id' => '',
+ 'whatsapp_default_phone_number_id' => '',
+ 'ai_provider' => 'mock',
+ 'openai_api_key' => '',
+ 'openai_model' => 'gpt-4o-mini',
+ 'ai_max_tokens' => '500',
+ 'ai_default_prompt' => 'Eres un asistente virtual de Palmas360. Responde de forma amable y profesional.',
+];
+$insertStmt = $db->prepare("INSERT IGNORE INTO settings (`key`, `value`) VALUES (?, ?)");
+foreach ($seedSettings as $k => $v) {
+ $insertStmt->execute([$k, $v]);
+}
+
// ─── Usuario admin por defecto ────────────────────────────────────────────────
$email = 'admin@palmas360.com';