cambios importantes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"project": "/Users/lizandro/Documents/USITE/PROYECTOS/bot_palmas"
|
||||
}
|
||||
+589
@@ -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
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Cliente de ejemplo para que el ERP se comunique con bot-palmas360
|
||||
*/
|
||||
class BotPalmasClient
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $apiKey;
|
||||
|
||||
public function __construct(string $baseUrl, string $apiKey)
|
||||
{
|
||||
$this->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 |
|
||||
+1416
-89
File diff suppressed because it is too large
Load Diff
+98
-10
@@ -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());
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class AiBot
|
||||
{
|
||||
public static function process(array $company, array $context, string $input): ?array
|
||||
{
|
||||
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'ai');
|
||||
$ctxId = (int)$botCtx['id'];
|
||||
|
||||
$config = self::getConfig($company);
|
||||
$systemPrompt = $config['ai_prompt'] ?? self::defaultPrompt($company);
|
||||
|
||||
ConversationContext::addAiMessage($ctxId, ['role' => '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 <<<PROMPT
|
||||
Eres un asistente virtual de {$name}. Tu rol es:
|
||||
|
||||
1. Responder preguntas sobre los servicios y productos de {$name}.
|
||||
2. Ayudar a los clientes con información general.
|
||||
3. Ser amable, profesional y responder siempre en español.
|
||||
4. Si no sabes la respuesta, indica que un asesor se comunicará.
|
||||
5. No inventes información. Si no sabes algo, dilo honestamente.
|
||||
|
||||
Mantén las respuestas concisas (máximo 3 párrafos).
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
private static function getConfig(array $company): array
|
||||
{
|
||||
$json = $company['config_json'] ?? '';
|
||||
if ($json === '') return [];
|
||||
$config = json_decode($json, true);
|
||||
return is_array($config) ? $config : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class BotRouter
|
||||
{
|
||||
public static function route(array $company, array $context, string $input, string $inputType = 'text'): void
|
||||
{
|
||||
if (self::shouldSkipBot($company, $context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response = self::classifyAndProcess($company, $context, $input, $inputType);
|
||||
|
||||
if ($response === null) {
|
||||
self::log("Bot no generó respuesta para {$context['from']}");
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int)($company['requires_approval'] ?? 0) === 1) {
|
||||
self::saveForApproval($company, $context, $input, $response);
|
||||
return;
|
||||
}
|
||||
|
||||
self::enqueueResponse($response, $company);
|
||||
}
|
||||
|
||||
private static function saveForApproval(array $company, array $context, string $input, array $response): void
|
||||
{
|
||||
$botType = $company['bot_type'] ?? 'normal';
|
||||
|
||||
$result = PendingApproval::create(
|
||||
companyId: (int)$company['id'],
|
||||
phone: $context['from'],
|
||||
name: $context['name'] ?? null,
|
||||
incomingMsg: $input,
|
||||
botResponse: $response,
|
||||
botType: $botType,
|
||||
context: $context,
|
||||
);
|
||||
|
||||
self::log("Respuesta guardada para aprobación #{$result['id']} — empresa {$company['name']}");
|
||||
|
||||
self::notifyErp($company, $result['id']);
|
||||
}
|
||||
|
||||
private static function notifyErp(array $company, int $pendingId): void
|
||||
{
|
||||
$config = self::getConfig($company);
|
||||
$webhookUrl = $config['approval_webhook'] ?? '';
|
||||
|
||||
if ($webhookUrl === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'action' => '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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class CompanyApiClient
|
||||
{
|
||||
public static function forwardMessage(array $company, array $messageData): array
|
||||
{
|
||||
$endpoint = rtrim($company['api_base_url'], '/') . '/webhook/incoming';
|
||||
|
||||
$payload = [
|
||||
'company_id' => (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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class CompanyRepository
|
||||
{
|
||||
public static function findByPhoneNumberId(string $phoneNumberId): ?array
|
||||
{
|
||||
$stmt = db()->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class ConversationContext
|
||||
{
|
||||
public static function getOrCreate(int $companyId, string $phoneNumber, string $botType = 'normal'): array
|
||||
{
|
||||
$stmt = db()->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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class ErpMonitor
|
||||
{
|
||||
public static function checkAll(): array
|
||||
{
|
||||
$companies = CompanyRepository::findAll();
|
||||
$results = [];
|
||||
|
||||
foreach ($companies as $company) {
|
||||
$results[] = self::check($company);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
public static function check(array $company): array
|
||||
{
|
||||
$baseUrl = rtrim($company['api_base_url'] ?? '', '/');
|
||||
$healthUrl = $baseUrl !== '' ? $baseUrl . '/health' : '';
|
||||
$apiKey = $company['api_key'] ?? '';
|
||||
|
||||
if ($healthUrl === '') {
|
||||
return [
|
||||
'company_id' => (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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class ErpSync
|
||||
{
|
||||
public static function sync(): array
|
||||
{
|
||||
$apiUrl = env('ERP_SYNC_API_URL', '');
|
||||
$apiKey = env('ERP_SYNC_API_KEY', '');
|
||||
|
||||
if ($apiUrl === '') {
|
||||
return ['error' => '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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class NormalBot
|
||||
{
|
||||
public static function process(array $company, array $context, string $input): ?array
|
||||
{
|
||||
$config = self::getConfig($company);
|
||||
$commands = $config['commands'] ?? [];
|
||||
$flows = $config['flows'] ?? [];
|
||||
$menus = $config['menus'] ?? [];
|
||||
|
||||
$ctxId = null;
|
||||
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
|
||||
$ctxId = (int)$botCtx['id'];
|
||||
$currentNode = $botCtx['current_node'];
|
||||
|
||||
$normalized = self::normalize($input);
|
||||
|
||||
if ($currentNode !== null && isset($flows[$currentNode])) {
|
||||
return self::handleFlow($flows[$currentNode], $context, $company, $ctxId);
|
||||
}
|
||||
|
||||
$matchedCommand = null;
|
||||
foreach ($commands as $keyword => $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class OutboundWorker
|
||||
{
|
||||
private const BATCH_SIZE = 10;
|
||||
|
||||
public static function processQueue(): array
|
||||
{
|
||||
$processed = 0;
|
||||
$errors = [];
|
||||
|
||||
$items = self::dequeue();
|
||||
|
||||
foreach ($items as $item) {
|
||||
try {
|
||||
$result = self::process($item);
|
||||
|
||||
if ($result['success']) {
|
||||
$stmt = db()->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}"],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class PendingApproval
|
||||
{
|
||||
public static function create(int $companyId, string $phone, ?string $name, string $incomingMsg, ?array $botResponse, string $botType, array $context): array
|
||||
{
|
||||
$stmt = db()->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class Settings
|
||||
{
|
||||
private static ?array $cache = null;
|
||||
|
||||
public static function all(): array
|
||||
{
|
||||
if (self::$cache !== null) return self::$cache;
|
||||
$rows = db()->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class WhatsAppSender
|
||||
{
|
||||
private const API_VERSION = 'v18.0';
|
||||
private const BASE_URL = 'https://graph.facebook.com';
|
||||
|
||||
public static function sendText(string $to, string $text, string $phoneNumberId): array
|
||||
{
|
||||
return self::callApi($phoneNumberId, [
|
||||
'messaging_product' => '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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user