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 Palmas360  ·  Admin

+

Palmas360 Palmas360

Somos19D
@@ -502,11 +580,28 @@ HTML;
+ +
{$sTotal}
📡 Total hoy
{$sMsgs}
💬 Mensajes texto
{$sMedia}
📎 Multimedia
{$sStatuses}
📊 Estados
+
{$sPending}
⏳ Pendientes aprobación
+
{$companyCount}
🏢 Empresas
+
+ +
+ 📡 Estado ERP + Cargando...
@@ -517,6 +612,7 @@ HTML; + {$totalStr} resultado(s) @@ -524,11 +620,12 @@ HTML;
- +
+ {$companyTh} @@ -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 + + + +
+
+

Palmas360 Pendientes de Aprobación

+
+
+ 👤 {$userName} + Salir +
+
+ +
+
+ +
ID HoraNúmero Nombre Tipo
+ + '; + 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 " + + + + + + + + "; + } + echo '
IDEmpresaDeMensaje recibidoRespuestaEstadoAcción
{$id}{$company}{$from}{$inMsg}{$reply}{$status} + + +
'; + } + 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 + + + +
+
+

Palmas360 Empresas

+
+
+ 👤 {$userName} + Salir +
+
+ +
+ {$toastHtml} +
+ Listado de Empresas + ➕ Agregar Empresa +
+
+HTML; + if (empty($companies)) { + echo '
No hay empresas registradas. Sincronizar desde el ERP
'; + } else { + echo ' + + '; + 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 " + + + + + + + + + + "; + } + echo '
IDNombreWhatsApp Phone IDTeléfonoBot TypeApruebaActivoAPI URLAcciones
{$id}{$name}
{$dname}
{$pid}{$phone}{$bot}' . ($apr ? 'Sí' : 'No') . "' . ($act ? 'Sí' : 'No') . "{$url} + ✎ Editar + 🗑 Eliminar +
'; + } + 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 .= "\n"; + } + + $pageTitle = $isEdit ? 'Editar Empresa' : 'Nueva Empresa'; + + http_response_code(200); + header('Content-Type: text/html; charset=utf-8'); + echo << + + + + + {$pageTitle} — Palmas360 + + + +
+
+

Palmas360 {$pageTitle}

+
+
+ 👤 {$userName} + Salir +
+
+ +
+
+

{$pageTitle}

+ +HTML; + if ($isEdit) { + echo ''; + } + echo << +
+ + +
Nombre interno de la empresa
+
+
+ + +
Nombre visible en reportes (opcional)
+
+
+
+
+ + +
ID numérico del número de teléfono de WhatsApp Business
+
+
+ + +
Número telefónico visible (opcional)
+
+
+
+
+ + +
Endpoint base del ERP para esta empresa
+
+
+ + +
Clave API para autenticación ERP ↔ Bot (opcional)
+
+
+
+
+ + +
Comportamiento del bot: normal (reglas), AI (inteligente), híbrido (combinado)
+
+
+ + +
+
+ + +
+
+
+ + +
Configuración adicional del bot en formato JSON
+
+
+ + Cancelar +
+ +
+
+ + +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 + + + +
+
+

Palmas360 Sincronizar Empresas

+
+
+ 👤 {$userName} + Salir +
+
+ +
+
+
{$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 + + + +
+
+

Palmas360 Procesar Cola de Mensajes

+
+
+ 👤 {$userName} + Salir +
+
+ +
+
+
{$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 + + + +
+
+

Palmas360 Configuración

+
+
+ 👤 {$userName} + Salir +
+
+ +
+ {$toastHtml} +
+HTML; + foreach ($fields as $sectionTitle => $sectionFields) { + echo '
'; + echo '
' . self::h($sectionTitle) . '
'; + echo '
'; + foreach ($sectionFields as $f) { + $key = $f['key']; + $val = self::h($settings[$key] ?? ''); + $label = self::h($f['label']); + $placeholder = self::h($f['placeholder'] ?? ''); + $full = ($f['type'] === 'textarea') ? ' fw' : ''; + echo "
"; + echo ""; + if ($f['type'] === 'select' && !empty($f['options'])) { + echo ""; + } elseif ($f['type'] === 'textarea') { + echo ""; + } elseif ($f['type'] === 'number') { + echo ""; + } else { + echo ""; + } + echo "
"; + } + echo '
'; + } + echo << + Cancelar + +
+ +
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 + + + +
+
+

Palmas360 Chat

+
+
+ 👤 {$userName} + Salir +
+
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
💬
+
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 + + + + + + +
+
+
+ 🌴 PALMAS360 · ERP Palmicultura +
+

Bot WhatsApp + IA

+

+ Módulo de captura de datos de campo, validación inteligente, aprobación supervisada + y notificaciones automatizadas mediante WhatsApp para los clientes de PALMAS360. +

+
+ 📅 Mayo 2026 + 🏢 Multi-tenant · 30+ empresas + 📲 WhatsApp Business API + 🤖 IA generativa +
+
+
+ + +
+
+ + + + + +
+
+
🎯
+
+

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

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FuncionalidadDentro del alcanceFuera del alcance v1
Captura de datos de campoLabores diarias, ciclos de cosecha, tiquetes de básculaOtros módulos del ERP (nómina, fertilización…)
Identificación de usuarioPor número de teléfono registrado en la empresaAutenticación biométrica / 2FA por WhatsApp
Routing multi-tenantDespacho automático al tenant correcto (≈30 empresas)Creación de nuevos tenants desde WhatsApp
Dashboard de aprobaciónRevisión, edición y aprobación por el supervisorAprobación masiva sin revisión
Validación con IAErrores de parametrización, lotes duplicados, campos faltantesLógica de negocio financiera compleja
Notificaciones salientesTareas programadas desde PALMAS360 → WhatsAppCampañas de marketing / mensajes masivos
Informes por chatInformes disponibles en el ERP, vía lenguaje naturalGeneración de informes ad-hoc nuevos
Log de WhatsAppHistorial de mensajes por empresa y por númeroArchivado 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

+
+ + +
+ + +
+
+
🌴
+
PALMAS360 Bot
En línea
+
+
+
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
+
+ + +
+
+
🌴
+
PALMAS360 Bot
En línea
+
+
+
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
+
+ + +
+
+
🌴
+
PALMAS360 Bot
En línea
+
+
+
+ ✅ 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
+
+ + +
+
+
🌴
+
PALMAS360 Bot
En línea
+
+
+
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

+
+
+ +
+ + +
+
+
📊
+
+

7. Flujo · Solicitud de Informes por WhatsApp

+

La IA mapea el lenguaje natural a una función de informe ya existente en PALMAS360 y retorna el resultado por chat

+
+
+ + +
+
⚙️
+
+
Principio fundamental: los informes deben estar pre-construidos
+
+ El bot no genera informes nuevos. Solo puede entregar informes que ya existen como funciones o endpoints en PALMAS360.
+ La IA hace dos cosas: (1) reconoce a qué función de informe corresponde la solicitud del usuario, + y (2) extrae los parámetros necesarios (fechas, lote, trabajador, etc.) para llamarla.
+ Si el informe pedido no está implementado en PALMAS360, el bot responde con el catálogo de informes disponibles. +
+
+
+ +
+
+flowchart TD + A([👷 Usuario escribe:\n'Dame la producción\nde esta semana']) --> B[Identificar empresa\npor número] + B --> C[🤖 IA clasifica intención:\nSOLICITUD DE INFORME] + C --> D["🔍 IA mapea a función existente:\ninforme_produccion_diaria()\n+ extrae parámetros:\nfecha_inicio, fecha_fin, lote"] + D --> E{¿Función de informe\nexiste en PALMAS360?} + E -->|No existe| F["🤖 Bot lista el catálogo\nde informes disponibles"] + E -->|Sí| G[🔐 Verificar permisos\ndel número solicitante] + G --> H{¿Tiene\nacceso?} + H -->|No| I[🤖 Acceso denegado.\nContactar al administrador] + H -->|Sí| J["⚙️ Llama función PALMAS360:\ninforme_produccion_diaria(\n empresa, fecha_inicio,\n fecha_fin, lote\n)"] + J --> K[🤖 IA formatea la respuesta\npara texto de WhatsApp] + K --> L[📲 Enviar informe\npor WhatsApp] + L --> M([👷 Usuario recibe\nel informe en su chat]) + + style A fill:#e8f5eb,stroke:#2aa147 + style C fill:#e3f2fd,stroke:#1976d2 + style D fill:#fff3e0,stroke:#f4b942 + style F fill:#fff3e0,stroke:#f4b942 + style I fill:#fee2e2,stroke:#b91c1c + style J fill:#e3f2fd,stroke:#1976d2 + style M fill:#e8f5eb,stroke:#2aa147 +
+

Figura 5 · La IA mapea la solicitud a una función pre-existente de PALMAS360

+
+ + +
+
📋
+
+

Catálogo de Funciones de Informes

+

Solo estos informes pre-construidos en PALMAS360 están disponibles por WhatsApp

+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Función PALMAS360DescripciónParámetros requeridosEjemplo de solicitud WhatsApp
informe_produccion_diaria()Producción por lote en un rango de fechasfecha_inicio, fecha_fin, lote (opcional)"Dame la producción de esta semana"
informe_labores_diarias()Labores ejecutadas por trabajador o por lotefecha_inicio, fecha_fin, trabajador / lote"¿Cuántas labores hizo Juan Pérez este mes?"
informe_ciclos_cosecha()Resumen de ciclos de cosecha activos o cerradosfecha, lote (opcional), num_ciclo (opcional)"Estado del último ciclo del lote 5"
informe_rendimientos_cosecha()Rendimiento histórico de cosecha por lotelote, fecha_inicio, fecha_fin"Rendimiento del lote 7 en lo que va del año"
informe_tiquetes_bascula()Listado de tiquetes de báscula con pesos y extractorasfecha (o rango), extractora (opcional)"Tiquetes de hoy" / "Tiquetes de esta semana"
informe_pesos_racimos()Pesos de racimos registrados en campofecha, lote (opcional)"Pesos de racimos del lote 3 ayer"
informe_pluviosidad()Registros de lluvia por períodofecha_inicio, fecha_fin"¿Cuánto llovió este mes?"
informe_ausentismo()Ausentismo laboral por trabajador o generalfecha_inicio, fecha_fin, trabajador (opcional)"Ausentismo de esta quincena"
informe_inventario_bodega()Stock actual de productos en bodegabodega (opcional), producto (opcional)"¿Cuánto fertilizante hay en bodega?"
informe_produccion_proyectada()Producción proyectada vs real por períodofecha_inicio, fecha_fin, lote (opcional)"¿Cómo vamos contra lo proyectado?"
+
+ +
+ 🔧 Para agregar un informe nuevo al bot: primero debe existir como función/endpoint en PALMAS360, + luego se registra en el catálogo del bot con su nombre, parámetros y las frases de ejemplo que la IA debe reconocer. + Sin ese registro previo, el bot no puede entregarlo. +
+ +
+ +
+ + +
+
+
📝
+
+

8. Requerimientos Funcionales

+

Listado priorizado de funcionalidades a implementar

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#MóduloRequerimientoPrioridad
RF-01IdentificaciónRegistrar números de WhatsApp por usuario y por empresa en PALMAS360Alta
RF-02RoutingEnrutar automáticamente cada mensaje al tenant correcto según el número remitenteAlta
RF-03CapturaCapturar 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 dashboardAlta
RF-04IAAplicar 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ónAlta
RF-05Validación IAValidar datos contra la parametrización del tenant (lotes, ciclos, usuarios, rangos)Alta
RF-06DashboardInterfaz de aprobación en PALMAS360 con editor de campos y panel de alertas de IAAlta
RF-07AprobaciónNada se guarda en el ERP sin aprobación explícita del supervisorAlta
RF-08LogSección en PALMAS360 con historial de conversaciones WhatsApp por empresa y númeroAlta
RF-09NotificacionesProgramar envíos de WhatsApp desde el módulo de tareas de PALMAS360Media
RF-10InformesProcesar solicitudes de informes en lenguaje natural y entregar respuesta por WhatsAppMedia
RF-11PermisosControl de acceso por número: qué puede registrar y qué informes puede solicitarMedia
RF-12ConfirmacionesEnviar confirmación al usuario por WhatsApp cuando su dato sea aprobado o rechazadoMedia
RF-13Multi-idiomaSoporte para mensajes en español colombiano con variantes regionalesBaja
RF-14MultimediaAceptar fotos de tiquetes y extraer datos con OCR + IABaja
+
+
+ +
+ + +
+
+
🚀
+
+

9. Fases de Implementación

+

Plan de desarrollo en etapas priorizadas

+
+
+
+ +
+
+
1
+
+
+
+ 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.

+
+
+ +
+
+
2
+
+
+
+ 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.

+
+
+ +
+
+
3
+
+
+
+ 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.

+
+
+ +
+
+
4
+
+
+
+ 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.

+
+
+ +
+
+
5
+
+
+
+ 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
+
+
📲 Mensaje
WhatsApp
+
+
📋 wa_log
Historial
+
+
wa_solicitudes
Pendiente
+
+
🖥️ Dashboard
IA + Supervisor
+
+
✅ Click Guardar
Límite del requerimiento
+
+
📚 BD existente
PALMAS360 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

+ +
+ + + +
+ + + + + + + + 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';