cambios importantes

This commit is contained in:
Lizandro Guarnizo
2026-06-12 12:18:21 -05:00
parent efebec3c1b
commit bc422e0a1c
20 changed files with 5348 additions and 99 deletions
+16
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
{
"project": "/Users/lizandro/Documents/USITE/PROYECTOS/bot_palmas"
}
+589
View File
@@ -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 |
File diff suppressed because it is too large Load Diff
+98 -10
View File
@@ -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
+254
View File
@@ -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 ────────────────────────────────────────────────────────────────
+155
View File
@@ -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 : [];
}
}
+173
View File
@@ -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);
}
}
}
+70
View File
@@ -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,
];
}
}
+82
View File
@@ -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;
}
}
+81
View File
@@ -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]);
}
}
+120
View File
@@ -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,
];
}
}
+86
View File
@@ -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,
]);
}
}
+240
View File
@@ -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;
}
}
+96
View File
@@ -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}"],
};
}
}
+113
View File
@@ -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();
}
}
+40
View File
@@ -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;
}
}
+106
View File
@@ -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,
];
}
}
+128
View File
@@ -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';