From 0544e8146323024b44ba5e514ae4c040c9a7d3eb Mon Sep 17 00:00:00 2001
From: lizandrogd <77708265+lizandrogd@users.noreply.github.com>
Date: Tue, 13 Jan 2026 01:48:33 -0500
Subject: [PATCH] delete test
---
README_LABORATORIO.md | 298 ++++++++++
api/test_api.php | 152 -----
api/test_db_connection.php | 38 --
install_laboratorio.php | 267 +++++++++
migrations/add_conversation_states.sql | 29 +
services/BotServiceLab.php | 387 +++++++++++++
services/BusinessHoursService.php | 221 ++++++++
services/ConversationStateService.php | 293 ++++++++++
services/MenuService.php | 222 ++++++++
services/NLPService.php | 377 +++++++++++++
services/PatientDataValidator.php | 315 +++++++++++
setup_laboratorio_ximena.sql | 732 +++++++++++++++++++++++++
test.php | 204 -------
test_api.php | 175 ------
test_api_simple.php | 7 -
test_basico.php | 57 --
test_config.php | 214 --------
test_config_system.php | 452 ---------------
test_direct_api.php | 100 ----
test_functions.html | 209 -------
test_menus.php | 200 -------
test_menus_api.html | 153 ------
test_menus_frontend.html | 203 -------
test_modal.html | 140 -----
test_navigation.html | 85 ---
test_pass.php | 3 -
test_ses_fix.html | 113 ----
test_simple_config.php | 104 ----
test_template_direct.php | 56 --
test_text_message.php | 92 ----
test_webhook.php | 366 -------------
test_whatsapp_completo.php | 162 ------
test_whatsapp_config.php | 52 --
whatsapp_tester.zip | Bin 0 -> 583367 bytes
34 files changed, 3141 insertions(+), 3337 deletions(-)
create mode 100644 README_LABORATORIO.md
delete mode 100644 api/test_api.php
delete mode 100644 api/test_db_connection.php
create mode 100644 install_laboratorio.php
create mode 100644 migrations/add_conversation_states.sql
create mode 100644 services/BotServiceLab.php
create mode 100644 services/BusinessHoursService.php
create mode 100644 services/ConversationStateService.php
create mode 100644 services/MenuService.php
create mode 100644 services/NLPService.php
create mode 100644 services/PatientDataValidator.php
create mode 100644 setup_laboratorio_ximena.sql
delete mode 100644 test.php
delete mode 100644 test_api.php
delete mode 100644 test_api_simple.php
delete mode 100644 test_basico.php
delete mode 100644 test_config.php
delete mode 100644 test_config_system.php
delete mode 100644 test_direct_api.php
delete mode 100644 test_functions.html
delete mode 100644 test_menus.php
delete mode 100644 test_menus_api.html
delete mode 100644 test_menus_frontend.html
delete mode 100644 test_modal.html
delete mode 100644 test_navigation.html
delete mode 100644 test_pass.php
delete mode 100644 test_ses_fix.html
delete mode 100644 test_simple_config.php
delete mode 100644 test_template_direct.php
delete mode 100644 test_text_message.php
delete mode 100644 test_webhook.php
delete mode 100644 test_whatsapp_completo.php
delete mode 100644 test_whatsapp_config.php
create mode 100644 whatsapp_tester.zip
diff --git a/README_LABORATORIO.md b/README_LABORATORIO.md
new file mode 100644
index 0000000..6686c79
--- /dev/null
+++ b/README_LABORATORIO.md
@@ -0,0 +1,298 @@
+# 🏥 Sistema de Chatbot WhatsApp - Laboratorio Ximena Caicedo
+
+## 📋 Descripción
+
+Sistema completo de chatbot para WhatsApp diseñado específicamente para el Laboratorio Ximena Caicedo en Cúcuta, Colombia. Incluye flujos conversacionales inteligentes, detección de horarios, gestión de estados, respuestas automáticas y mucho más.
+
+## ✨ Características Principales
+
+### 🤖 Bot Inteligente
+- ✅ Procesamiento de Lenguaje Natural (NLP) básico
+- ✅ Detección de palabras clave y sinónimos
+- ✅ Respuestas automáticas contextuales
+- ✅ Detección de sentimientos y frustración
+- ✅ Escalamiento automático a asesores humanos
+
+### 📅 Gestión de Horarios
+- ✅ Detección automática de horario de atención
+- ✅ Mensajes diferentes dentro/fuera de horario
+- ✅ Configuración por día de la semana
+- ✅ Soporte para horarios partidos (7-12, 14-17)
+
+### 🗂️ Sistema de Menús
+- ✅ Menú principal con 5 opciones
+- ✅ Submenú de exámenes con información detallada
+- ✅ Navegación intuitiva con números
+- ✅ Opción de volver al menú anterior
+
+### 🔄 Gestión de Estados
+- ✅ Tracking del estado de cada conversación
+- ✅ Flujos de agendamiento paso a paso
+- ✅ Validación de datos del paciente
+- ✅ Timeout automático de estados inactivos
+
+### 💬 Respuestas Automáticas
+- ✅ Más de 15 triggers configurados
+- ✅ Respuestas por palabras clave
+- ✅ Información de precios, horarios, ubicación
+- ✅ Instrucciones para exámenes específicos
+
+### 📸 Procesamiento de Multimedia
+- ✅ Recepción de órdenes médicas
+- ✅ Fotos de documentos de identidad
+- ✅ Validación de imágenes
+- ✅ Almacenamiento seguro
+
+## 🚀 Instalación
+
+### Requisitos Previos
+- PHP 8.0 o superior
+- MySQL 5.7 o superior
+- Servidor web (Apache/Nginx)
+- Cuenta de WhatsApp Business API
+
+### Paso 1: Clonar/Descargar Archivos
+Asegúrate de tener todos los archivos en tu servidor.
+
+### Paso 2: Configurar Base de Datos
+1. Crea una base de datos MySQL
+2. Importa las tablas base si no existen:
+```sql
+-- Ejecutar los scripts SQL en este orden:
+1. create_tables.sql (tablas base)
+2. migrations/add_conversation_states.sql (tabla de estados)
+3. setup_laboratorio_ximena.sql (datos del laboratorio)
+```
+
+### Paso 3: Configurar Credenciales
+Edita `config/config.php` con tus credenciales:
+```php
+define('DB_HOST', 'localhost');
+define('DB_NAME', 'whatsapp_bot');
+define('DB_USER', 'tu_usuario');
+define('DB_PASS', 'tu_password');
+```
+
+### Paso 4: Ejecutar Instalación Automática
+Visita en tu navegador:
+```
+http://tu-dominio.com/install_laboratorio.php
+```
+
+Este script:
+- ✅ Verifica la conexión a la base de datos
+- ✅ Crea la tabla de estados
+- ✅ Importa menús y respuestas automáticas
+- ✅ Verifica los servicios del sistema
+- ✅ Muestra estadísticas finales
+
+### Paso 5: Configurar WhatsApp API
+1. Ve al panel de administración: `http://tu-dominio.com/index.php`
+2. Navega a la pestaña "Configuración"
+3. Ingresa:
+ - Token de Acceso de WhatsApp Business API
+ - Phone Number ID
+ - Webhook Verify Token
+ - API URL (por defecto: https://graph.facebook.com/v22.0/)
+
+### Paso 6: Configurar Webhook
+1. En Meta for Developers, configura el webhook:
+ - URL: `https://tu-dominio.com/api/webhook.php`
+ - Verify Token: El mismo que configuraste en el paso anterior
+ - Suscripciones: `messages`, `message_status`
+
+## 📚 Estructura de Archivos
+
+```
+whatsapp/
+├── services/
+│ ├── BusinessHoursService.php # Gestión de horarios
+│ ├── ConversationStateService.php # Estados de conversación
+│ ├── NLPService.php # Procesamiento de lenguaje
+│ ├── MenuService.php # Gestión de menús
+│ ├── PatientDataValidator.php # Validación de datos
+│ ├── BotServiceLab.php # Servicio principal del bot
+│ └── WhatsAppService.php # Integración con WhatsApp API
+├── api/
+│ └── webhook.php # Recepción de mensajes
+├── migrations/
+│ └── add_conversation_states.sql # Migración de tabla de estados
+├── setup_laboratorio_ximena.sql # Setup completo del laboratorio
+├── install_laboratorio.php # Instalador automático
+└── README_LABORATORIO.md # Este archivo
+```
+
+## 🎯 Flujos Implementados
+
+### 1. Menú Principal
+```
+1️⃣ Agendar toma a domicilio
+2️⃣ Consultar resultados
+3️⃣ Información sobre exámenes
+4️⃣ Ubicación y horarios
+5️⃣ Hablar con un asesor
+```
+
+### 2. Submenú de Exámenes
+```
+1️⃣ Pruebas de embarazo
+2️⃣ Prueba de paternidad
+3️⃣ Helicobacter pylori (en aliento)
+4️⃣ Orina de 24 horas
+5️⃣ Solicitar cotización
+0️⃣ Volver al menú principal
+```
+
+### 3. Palabras Clave Automáticas
+- **"precio", "costo", "cuánto"** → Información de precios
+- **"resultado", "resultados"** → Instrucciones para descargar
+- **"horario", "cuando"** → Horarios de atención
+- **"domicilio", "casa"** → Servicio a domicilio
+- **"embarazo"** → Pruebas de embarazo
+- **"paternidad", "ADN"** → Prueba de paternidad
+- **"ubicación", "donde"** → Dirección y mapa
+- **"asesor", "ayuda", "persona"** → Transferir a humano
+- **"gracias"** → Mensaje de despedida
+- **"menú", "inicio"** → Mostrar menú principal
+
+## ⚙️ Configuración Avanzada
+
+### Horarios de Atención
+Edita en la base de datos o desde el panel:
+```sql
+UPDATE system_config
+SET config_value = '7:00-12:00,14:00-17:00'
+WHERE config_key = 'business_hours_weekday';
+
+UPDATE system_config
+SET config_value = '8:00-11:00'
+WHERE config_key = 'business_hours_saturday';
+```
+
+### Agregar Nueva Respuesta Automática
+Desde el panel de administración:
+1. Ve a "Respuestas Automáticas"
+2. Click en "Nueva Respuesta"
+3. Configura:
+ - Tipo de trigger (keyword, contains, exact, etc.)
+ - Palabras clave (separadas por comas)
+ - Texto de respuesta
+ - Prioridad (1-100, mayor = más prioridad)
+
+### Personalizar Menús
+1. Ve a "Menús Configurados"
+2. Click en el menú que quieres editar
+3. Modifica el mensaje de bienvenida o las opciones
+4. Guarda los cambios
+
+## 🔧 Personalización
+
+### Cambiar Nombre del Laboratorio
+Edita `setup_laboratorio_ximena.sql` antes de instalar, reemplaza:
+```sql
+'Laboratorio Ximena Caicedo' → 'Tu Laboratorio'
+```
+
+### Modificar Valores de Exámenes
+Edita los mensajes en la tabla `menu_options` o `autoresponses`:
+```sql
+UPDATE menu_options
+SET response_text = REPLACE(response_text, '$28.000', '$30.000')
+WHERE response_text LIKE '%$28.000%';
+```
+
+### Agregar Nuevo Examen al Submenú
+```sql
+INSERT INTO menu_options (menu_id, option_key, option_order, option_text, response_type, response_text, is_active)
+VALUES
+(
+ (SELECT id FROM menus WHERE menu_key = 'lab_menu_examenes'),
+ '6',
+ 6,
+ 'Nuevo Examen',
+ 'text',
+ 'Información del nuevo examen...',
+ 1
+);
+```
+
+## 📊 Monitoreo y Logs
+
+### Ver Conversaciones Recientes
+```sql
+SELECT phone_number, message_text, direction, created_at
+FROM conversations
+ORDER BY created_at DESC
+LIMIT 50;
+```
+
+### Ver Estados Activos
+```sql
+SELECT phone_number, state, state_data, updated_at
+FROM user_states
+WHERE updated_at > DATE_SUB(NOW(), INTERVAL 1 HOUR);
+```
+
+### Ver Logs de Webhook
+```sql
+SELECT * FROM webhook_logs
+ORDER BY created_at DESC
+LIMIT 20;
+```
+
+## 🐛 Solución de Problemas
+
+### El bot no responde
+1. Verifica que el webhook esté configurado correctamente
+2. Revisa los logs de webhook: `SELECT * FROM webhook_logs ORDER BY created_at DESC LIMIT 1`
+3. Verifica las credenciales de WhatsApp API en "Configuración"
+
+### Mensajes fuera de horario no funcionan
+1. Verifica la zona horaria del servidor: `date_default_timezone_get()`
+2. Revisa la configuración de horarios en `system_config`
+3. Asegúrate de que `BusinessHoursService.php` existe
+
+### Estados no se guardan
+1. Verifica que la tabla `user_states` existe:
+```sql
+SHOW TABLES LIKE 'user_states';
+```
+2. Si no existe, ejecuta: `migrations/add_conversation_states.sql`
+
+### NLP no detecta palabras clave
+1. Revisa que las palabras estén en minúsculas en la BD
+2. Verifica que `autoresponses` tiene registros activos:
+```sql
+SELECT COUNT(*) FROM autoresponses WHERE is_active = 1;
+```
+3. Aumenta la prioridad de la respuesta automática
+
+## 📞 Soporte
+
+Para soporte técnico o consultas:
+- 📧 Email: soporte@ejemplo.com
+- 📱 WhatsApp: +57 XXX XXX XXXX
+- 💻 GitHub: [repositorio]
+
+## 📝 Changelog
+
+### Versión 1.0.0 (13 de enero de 2026)
+- ✅ Implementación completa del sistema
+- ✅ Menús del laboratorio
+- ✅ 15+ respuestas automáticas
+- ✅ Detección de horarios
+- ✅ Gestión de estados
+- ✅ NLP básico
+- ✅ Validación de datos de pacientes
+- ✅ Instalador automático
+
+## 📜 Licencia
+
+Este sistema fue desarrollado específicamente para Laboratorio Ximena Caicedo.
+Todos los derechos reservados © 2026
+
+---
+
+**¡Sistema listo para producción! 🚀**
+
+Para comenzar, ejecuta `install_laboratorio.php` y sigue las instrucciones.
diff --git a/api/test_api.php b/api/test_api.php
deleted file mode 100644
index fd4e8fb..0000000
--- a/api/test_api.php
+++ /dev/null
@@ -1,152 +0,0 @@
- true,
- 'message' => 'API funcionando correctamente',
- 'timestamp' => date('Y-m-d H:i:s'),
- 'method' => $method
- ]);
- exit;
- }
-
- switch ($method) {
- case 'GET':
- switch ($action) {
- case 'status':
- // Estado general del sistema
- $allConfigs = getAllConfigsFromDB();
- $whatsappValidation = validateWhatsAppConfigFromDB();
-
- echo json_encode([
- 'success' => true,
- 'data' => [
- 'total_configs' => count($allConfigs),
- 'whatsapp_configured' => $whatsappValidation['valid'],
- 'database_migrated' => !empty($allConfigs),
- 'installation_completed' => isInstallationCompleted(),
- 'server_time' => date('Y-m-d H:i:s')
- ]
- ]);
- break;
-
- case 'all':
- // Obtener todas las configuraciones
- $configs = getAllConfigsFromDB();
- echo json_encode([
- 'success' => true,
- 'data' => $configs
- ]);
- break;
-
- case 'whatsapp':
- // Obtener configuración de WhatsApp
- $whatsappConfig = getWhatsAppConfigFromDB();
- echo json_encode([
- 'success' => true,
- 'data' => $whatsappConfig
- ]);
- break;
-
- case 'test_db':
- // Test de conexión a BD
- try {
- $pdo = createDbConnection();
- $tableExists = $pdo->query("SHOW TABLES LIKE 'system_config'")->rowCount() > 0;
-
- echo json_encode([
- 'success' => true,
- 'data' => [
- 'db_connected' => true,
- 'table_exists' => $tableExists,
- 'config_count' => $tableExists ? $pdo->query("SELECT COUNT(*) FROM system_config")->fetchColumn() : 0
- ]
- ]);
- } catch (Exception $e) {
- echo json_encode([
- 'success' => false,
- 'error' => 'Error de BD: ' . $e->getMessage()
- ]);
- }
- break;
-
- default:
- throw new Exception('Acción GET no reconocida: ' . $action);
- }
- break;
-
- case 'POST':
- $input = json_decode(file_get_contents('php://input'), true);
-
- switch ($action) {
- case 'save_test':
- // Guardar configuración de prueba
- $key = 'api_test_' . time();
- $value = 'Test desde API - ' . date('Y-m-d H:i:s');
-
- $result = saveConfigToDB($key, $value, 'Prueba desde API');
-
- echo json_encode([
- 'success' => $result,
- 'message' => $result ? 'Configuración guardada correctamente' : 'Error guardando configuración',
- 'data' => [
- 'key' => $key,
- 'value' => $value
- ]
- ]);
- break;
-
- case 'save_whatsapp_test':
- // Guardar config de WhatsApp de prueba
- $testConfig = [
- 'token' => 'test_token_' . time(),
- 'phone_number_id' => '123456789',
- 'webhook_verify_token' => 'webhook_' . time()
- ];
-
- $result = saveWhatsAppConfigToDB($testConfig);
-
- echo json_encode([
- 'success' => $result,
- 'message' => $result ? 'WhatsApp config guardada' : 'Error guardando WhatsApp config',
- 'data' => $testConfig
- ]);
- break;
-
- default:
- throw new Exception('Acción POST no reconocida: ' . $action);
- }
- break;
-
- default:
- throw new Exception('Método HTTP no permitido: ' . $method);
- }
-} catch (Exception $e) {
- http_response_code(500);
- echo json_encode([
- 'success' => false,
- 'error' => $e->getMessage(),
- 'trace' => $e->getTraceAsString()
- ]);
-}
-?>
\ No newline at end of file
diff --git a/api/test_db_connection.php b/api/test_db_connection.php
deleted file mode 100644
index d05441d..0000000
--- a/api/test_db_connection.php
+++ /dev/null
@@ -1,38 +0,0 @@
- 'Iniciando test de conexión DB...',
- 'timestamp' => date('Y-m-d H:i:s')
- ]);
- echo "\n\n";
-
- $db = Database::getInstance();
-
- echo json_encode([
- 'success' => true,
- 'message' => 'Conexión a base de datos exitosa',
- 'database' => DB_NAME,
- 'host' => DB_HOST
- ]);
- echo "\n\n";
-
- // Test de tabla
- $tables = $db->fetchAll("SHOW TABLES");
- echo json_encode([
- 'success' => true,
- 'message' => 'Tablas encontradas',
- 'tables' => $tables
- ]);
-
-} catch (Exception $e) {
- echo json_encode([
- 'success' => false,
- 'error' => $e->getMessage(),
- 'trace' => $e->getTraceAsString()
- ]);
-}
-?>
\ No newline at end of file
diff --git a/install_laboratorio.php b/install_laboratorio.php
new file mode 100644
index 0000000..11ad36b
--- /dev/null
+++ b/install_laboratorio.php
@@ -0,0 +1,267 @@
+
+
+
+
+
+
+ Instalación - Laboratorio Ximena Caicedo
+
+
+
+
+
+
+
+
+
+
Laboratorio Ximena Caicedo
+
Instalación del Sistema de Chatbot WhatsApp
+
+
+
+ ';
+ echo ' Verificando conexión a la base de datos...';
+ flush();
+
+ $db = Database::getInstance();
+
+ echo ' Conectado ';
+ echo '
';
+ $steps[] = ['name' => 'Conexión DB', 'status' => 'success'];
+
+ // PASO 2: Crear tabla de estados si no existe
+ echo '
';
+ echo ' Creando tabla de estados de conversación...';
+ flush();
+
+ $sqlStates = file_get_contents(__DIR__ . '/migrations/add_conversation_states.sql');
+ $statementsStates = array_filter(array_map('trim', explode(';', $sqlStates)));
+
+ foreach ($statementsStates as $statement) {
+ if (!empty($statement) && substr($statement, 0, 6) !== 'SELECT') {
+ $db->getConnection()->exec($statement);
+ }
+ }
+
+ echo ' Completado ';
+ echo '
';
+ $steps[] = ['name' => 'Tabla user_states', 'status' => 'success'];
+
+ // PASO 3: Importar menús y respuestas automáticas
+ echo '
';
+ echo ' Importando menús y respuestas automáticas...';
+ flush();
+
+ $sqlSetup = file_get_contents(__DIR__ . '/setup_laboratorio_ximena.sql');
+ $statementsSetup = array_filter(array_map('trim', explode(';', $sqlSetup)));
+
+ $menuCount = 0;
+ $responseCount = 0;
+
+ foreach ($statementsSetup as $statement) {
+ if (!empty($statement) && substr($statement, 0, 2) !== '--') {
+ if (stripos($statement, 'INSERT INTO menus') !== false) {
+ $menuCount++;
+ } elseif (stripos($statement, 'INSERT INTO autoresponses') !== false) {
+ $responseCount++;
+ }
+
+ try {
+ $db->getConnection()->exec($statement);
+ } catch (Exception $e) {
+ // Ignorar errores de duplicados
+ if (strpos($e->getMessage(), 'Duplicate entry') === false) {
+ throw $e;
+ }
+ }
+ }
+ }
+
+ echo " {$menuCount} menús, {$responseCount} respuestas ";
+ echo '
';
+ $steps[] = ['name' => 'Menús y respuestas', 'status' => 'success'];
+
+ // PASO 4: Verificar servicios
+ echo '
';
+ echo ' Verificando servicios del sistema...';
+ flush();
+
+ $services = [
+ 'BusinessHoursService',
+ 'ConversationStateService',
+ 'NLPService',
+ 'MenuService',
+ 'PatientDataValidator',
+ 'BotServiceLab'
+ ];
+
+ $servicesOk = true;
+ foreach ($services as $service) {
+ $file = __DIR__ . "/services/{$service}.php";
+ if (!file_exists($file)) {
+ echo " Falta {$service}.php ";
+ $servicesOk = false;
+ break;
+ }
+ }
+
+ if ($servicesOk) {
+ echo ' ' . count($services) . ' servicios verificados ';
+ $steps[] = ['name' => 'Servicios', 'status' => 'success'];
+ } else {
+ $success = false;
+ }
+
+ echo '
';
+
+ // PASO 5: Obtener estadísticas
+ echo '
';
+ echo ' Obteniendo estadísticas...';
+ flush();
+
+ $stmt = $db->query("
+ SELECT
+ (SELECT COUNT(*) FROM menus WHERE is_active = 1) as menus_activos,
+ (SELECT COUNT(*) FROM menu_options WHERE is_active = 1) as opciones_activas,
+ (SELECT COUNT(*) FROM autoresponses WHERE is_active = 1) as respuestas_activas,
+ (SELECT COUNT(*) FROM users) as total_usuarios
+ ");
+ $stats = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ echo ' Completado ';
+ echo '
';
+
+ // Mostrar resumen
+ if ($success) {
+ echo '
';
+ echo '
¡Instalación Completada Exitosamente!';
+ echo '
';
+ echo '
📊 Estadísticas del Sistema: ';
+ echo '
';
+ echo "Menús activos: {$stats['menus_activos']} ";
+ echo "Opciones de menú: {$stats['opciones_activas']} ";
+ echo "Respuestas automáticas: {$stats['respuestas_activas']} ";
+ echo "Usuarios registrados: {$stats['total_usuarios']} ";
+ echo ' ';
+ echo '
';
+ echo '
🚀 Próximos Pasos: ';
+ echo '
';
+ echo 'Configura tus credenciales de WhatsApp Business API en el panel de administración ';
+ echo 'Prueba el menú principal enviando un mensaje de WhatsApp ';
+ echo 'Revisa las respuestas automáticas en la sección correspondiente ';
+ echo 'Personaliza los mensajes según las necesidades del laboratorio ';
+ echo ' ';
+ echo '
';
+ echo '
';
+ }
+
+ } catch (Exception $e) {
+ $success = false;
+ echo '
';
+ echo ' Error: ' . htmlspecialchars($e->getMessage());
+ echo '
';
+
+ echo '
';
+ echo '
Error en la Instalación';
+ echo '
Hubo un problema durante la instalación. Por favor:
';
+ echo '
';
+ echo 'Verifica que la base de datos existe y tiene permisos correctos ';
+ echo 'Revisa la configuración en config/config.php ';
+ echo 'Consulta el error arriba para más detalles ';
+ echo 'Intenta ejecutar la instalación nuevamente ';
+ echo ' ';
+ echo '
';
+ echo '';
+ echo ' Reintentar Instalación';
+ echo ' ';
+ echo '
';
+ echo '
';
+ }
+ ?>
+
+
+
+
+
+
Documentación
+
Para más información sobre cómo usar el sistema:
+
+
+
+
+
+
+
+
+
+
diff --git a/migrations/add_conversation_states.sql b/migrations/add_conversation_states.sql
new file mode 100644
index 0000000..2166eee
--- /dev/null
+++ b/migrations/add_conversation_states.sql
@@ -0,0 +1,29 @@
+-- =====================================================
+-- MIGRACIÓN: Tabla de Estados de Conversación
+-- Fecha: 13 de enero de 2026
+-- =====================================================
+
+-- Crear tabla user_states si no existe
+CREATE TABLE IF NOT EXISTS user_states (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ phone_number VARCHAR(20) NOT NULL,
+ state VARCHAR(50) NOT NULL,
+ state_data TEXT NULL COMMENT 'JSON con datos adicionales del estado',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY unique_phone (phone_number),
+ INDEX idx_state (state),
+ INDEX idx_updated (updated_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Agregar columna parent_menu_id si no existe
+ALTER TABLE menus
+ADD COLUMN IF NOT EXISTS parent_menu_id INT NULL COMMENT 'ID del menú padre para submenús',
+ADD CONSTRAINT fk_parent_menu FOREIGN KEY (parent_menu_id) REFERENCES menus(id) ON DELETE SET NULL;
+
+-- Agregar índice para mejorar rendimiento
+ALTER TABLE autoresponses
+ADD INDEX IF NOT EXISTS idx_trigger_type (trigger_type),
+ADD INDEX IF NOT EXISTS idx_priority (priority DESC);
+
+SELECT '✅ Migración completada exitosamente' as status;
diff --git a/services/BotServiceLab.php b/services/BotServiceLab.php
new file mode 100644
index 0000000..f7bff87
--- /dev/null
+++ b/services/BotServiceLab.php
@@ -0,0 +1,387 @@
+db = Database::getInstance();
+ $this->whatsappService = new WhatsAppService();
+ $this->businessHoursService = new BusinessHoursService();
+ $this->stateService = new ConversationStateService();
+ $this->nlpService = new NLPService();
+ $this->menuService = new MenuService();
+ }
+
+ /**
+ * Procesa un mensaje entrante
+ * @param array $user
+ * @param string $messageText
+ * @param string $messageType
+ * @param string|null $mediaId
+ */
+ public function processMessage(array $user, string $messageText, string $messageType = 'text', ?string $mediaId = null): void
+ {
+ $phoneNumber = $user['phone_number'];
+
+ try {
+ // 1. Verificar si el usuario está con un asesor
+ if ($this->stateService->isWaitingForAdvisor($phoneNumber)) {
+ // No procesar automáticamente, está con asesor humano
+ $this->logMessage($phoneNumber, "Usuario con asesor, mensaje no procesado automáticamente");
+ return;
+ }
+
+ // 2. Verificar horario de atención
+ if (!$this->businessHoursService->isBusinessHours()) {
+ if ($this->businessHoursService->shouldShowOutOfHoursMessage($phoneNumber)) {
+ $message = $this->businessHoursService->getMessage();
+ $this->sendMessage($phoneNumber, $message);
+ }
+ return;
+ }
+
+ // 3. Verificar si el estado ha expirado
+ if ($this->stateService->isStateExpired($phoneNumber)) {
+ $this->stateService->clearState($phoneNumber);
+ }
+
+ // 4. Procesar según el tipo de mensaje
+ if ($messageType !== 'text' && $mediaId) {
+ $this->processMediaMessage($user, $messageType, $mediaId, $messageText);
+ return;
+ }
+
+ // 5. Verificar si es el primer mensaje (bienvenida)
+ if ($this->isFirstMessage($phoneNumber)) {
+ $this->sendWelcomeMessage($phoneNumber);
+ return;
+ }
+
+ // 6. Procesar mensaje de texto con NLP
+ $this->processTextMessage($user, $messageText);
+
+ } catch (Exception $e) {
+ error_log("Error en BotServiceLab->processMessage: " . $e->getMessage());
+ $this->sendMessage($phoneNumber, "Lo siento, ocurrió un error. Por favor intenta de nuevo o escribe *ASESOR* para ayuda.");
+ }
+ }
+
+ /**
+ * Procesa un mensaje de texto
+ * @param array $user
+ * @param string $messageText
+ */
+ private function processTextMessage(array $user, string $messageText): void
+ {
+ $phoneNumber = $user['phone_number'];
+
+ // Procesar con NLP para encontrar intención
+ $response = $this->nlpService->processMessage($messageText, $phoneNumber);
+
+ if (!$response) {
+ // No se encontró respuesta, enviar mensaje por defecto
+ $this->sendMessage($phoneNumber, "❓ No he comprendido tu mensaje.\n\nEscribe *MENÚ* para ver las opciones disponibles.");
+ return;
+ }
+
+ // Incrementar intentos si no entendió
+ if ($response['type'] === 'default') {
+ $attempts = $this->stateService->incrementAttempts($phoneNumber);
+
+ // Si son 3 intentos fallidos, transferir a asesor
+ if ($attempts >= 3) {
+ $this->transferToAdvisor($phoneNumber, 'No se pudo entender el mensaje después de 3 intentos');
+ return;
+ }
+ } else {
+ // Resetear intentos si entendió correctamente
+ $this->stateService->resetAttempts($phoneNumber);
+ }
+
+ // Detectar frustración
+ if ($this->nlpService->detectsFrustration($messageText)) {
+ $this->transferToAdvisor($phoneNumber, 'Frustración detectada');
+ return;
+ }
+
+ // Ejecutar acción según el tipo de respuesta
+ $this->executeResponse($phoneNumber, $response);
+ }
+
+ /**
+ * Ejecuta la acción según el tipo de respuesta
+ * @param string $phoneNumber
+ * @param array $response
+ */
+ private function executeResponse(string $phoneNumber, array $response): void
+ {
+ switch ($response['type']) {
+ case 'text':
+ $this->sendMessage($phoneNumber, $response['message']);
+ break;
+
+ case 'menu':
+ $menuId = $response['menu_id'] ?? null;
+ if ($menuId) {
+ $this->sendMenu($phoneNumber, $menuId);
+ }
+ break;
+
+ case 'template':
+ $templateName = $response['template_name'] ?? null;
+ if ($templateName) {
+ $this->sendTemplate($phoneNumber, $templateName);
+ }
+ break;
+
+ case 'transfer':
+ $message = $response['message'] ?? 'Conectando con asesor...';
+ $this->transferToAdvisor($phoneNumber, 'Solicitud del usuario', $message);
+ break;
+ }
+ }
+
+ /**
+ * Procesa mensajes multimedia
+ * @param array $user
+ * @param string $messageType
+ * @param string $mediaId
+ * @param string $caption
+ */
+ private function processMediaMessage(array $user, string $messageType, string $mediaId, string $caption = ''): void
+ {
+ $phoneNumber = $user['phone_number'];
+ $currentState = $this->stateService->getState($phoneNumber);
+
+ // Si está esperando foto de orden médica o documento
+ if ($currentState && in_array($currentState['state'], [
+ ConversationStateService::STATE_AWAITING_ORDER_PHOTO,
+ ConversationStateService::STATE_AWAITING_ID_PHOTO
+ ])) {
+ if ($messageType === 'image' || $messageType === 'document') {
+ $this->handleUploadedDocument($user, $mediaId, $currentState['state']);
+ } else {
+ $this->sendMessage($phoneNumber, "Por favor envía una imagen o documento PDF.");
+ }
+ return;
+ }
+
+ // Si no está esperando media, informar
+ $this->sendMessage($phoneNumber, "He recibido tu archivo. ¿En qué puedo ayudarte?\n\nEscribe *MENÚ* para ver las opciones.");
+ }
+
+ /**
+ * Maneja documentos subidos (órdenes médicas, ID, etc.)
+ * @param array $user
+ * @param string $mediaId
+ * @param string $expectedType
+ */
+ private function handleUploadedDocument(array $user, string $mediaId, string $expectedType): void
+ {
+ $phoneNumber = $user['phone_number'];
+
+ try {
+ // Descargar el archivo desde WhatsApp
+ $mediaUrl = $this->whatsappService->downloadMedia($mediaId);
+
+ if (!$mediaUrl) {
+ $this->sendMessage($phoneNumber, "❌ No pude descargar el archivo. Por favor intenta de nuevo.");
+ return;
+ }
+
+ // Guardar información del documento
+ if ($expectedType === ConversationStateService::STATE_AWAITING_ORDER_PHOTO) {
+ $this->stateService->updateStateData($phoneNumber, [
+ 'order_photo_url' => $mediaUrl,
+ 'order_received_at' => date('Y-m-d H:i:s')
+ ]);
+
+ $this->sendMessage($phoneNumber, "✅ Orden médica recibida.\n\nAhora necesito los datos del paciente:\n\n✓ Nombre completo:\n✓ Número de documento:\n✓ Dirección completa:\n✓ Número de celular:\n✓ Correo electrónico:\n✓ ¿Es particular o por seguro?\n\nEnvíalos en UN SOLO mensaje.");
+
+ $this->stateService->setState($phoneNumber, ConversationStateService::STATE_AWAITING_PATIENT_DATA);
+
+ } elseif ($expectedType === ConversationStateService::STATE_AWAITING_ID_PHOTO) {
+ $this->stateService->updateStateData($phoneNumber, [
+ 'id_photo_url' => $mediaUrl,
+ 'id_received_at' => date('Y-m-d H:i:s')
+ ]);
+
+ $this->sendMessage($phoneNumber, "✅ Documento de identidad recibido.\n\nProcesando tu solicitud...");
+
+ // Aquí iría lógica adicional según el flujo
+ $this->stateService->setState($phoneNumber, ConversationStateService::STATE_VALIDATING_DATA);
+ }
+
+ } catch (Exception $e) {
+ error_log("Error downloading media: " . $e->getMessage());
+ $this->sendMessage($phoneNumber, "❌ Hubo un problema al procesar el archivo. Por favor intenta nuevamente.");
+ }
+ }
+
+ /**
+ * Envía el mensaje de bienvenida
+ * @param string $phoneNumber
+ */
+ private function sendWelcomeMessage(string $phoneNumber): void
+ {
+ $message = $this->businessHoursService->getMessage();
+ $this->sendMessage($phoneNumber, $message);
+
+ // Si está en horario, enviar menú principal
+ if ($this->businessHoursService->isBusinessHours()) {
+ $mainMenuId = $this->menuService->getMainMenuId();
+ if ($mainMenuId) {
+ $this->stateService->setCurrentMenu($phoneNumber, $mainMenuId);
+ }
+ }
+ }
+
+ /**
+ * Envía un menú
+ * @param string $phoneNumber
+ * @param int $menuId
+ */
+ private function sendMenu(string $phoneNumber, int $menuId): void
+ {
+ $menu = $this->menuService->getMenu($menuId);
+
+ if (!$menu) {
+ $this->sendMessage($phoneNumber, "❌ Menú no encontrado.");
+ return;
+ }
+
+ $this->sendMessage($phoneNumber, $menu['welcome_message']);
+ $this->stateService->setCurrentMenu($phoneNumber, $menuId);
+ }
+
+ /**
+ * Transfiere la conversación a un asesor
+ * @param string $phoneNumber
+ * @param string $reason
+ * @param string $message
+ */
+ private function transferToAdvisor(string $phoneNumber, string $reason, string $message = null): void
+ {
+ // Enviar mensaje al usuario
+ $userMessage = $message ?? '👤 SOLICITUD DE ASESOR\n\nUn momento por favor, estamos conectando tu consulta con uno de nuestros asesores.\n\nEn breve te atenderemos.\n\n*Tu consulta es importante para nosotros* 💙';
+
+ $this->sendMessage($phoneNumber, $userMessage);
+
+ // Marcar en el estado
+ $this->stateService->markForTransfer($phoneNumber, $reason);
+
+ // Aquí se podría notificar al equipo de asesores
+ // Por ejemplo, enviar notificación, email, etc.
+ $this->logMessage($phoneNumber, "Transferido a asesor: $reason");
+ }
+
+ /**
+ * Envía una plantilla de WhatsApp
+ * @param string $phoneNumber
+ * @param string $templateName
+ * @param array $parameters
+ */
+ private function sendTemplate(string $phoneNumber, string $templateName, array $parameters = []): void
+ {
+ try {
+ $result = $this->whatsappService->sendTemplateMessage($phoneNumber, $templateName, $parameters);
+
+ if (!$result) {
+ $this->sendMessage($phoneNumber, "❌ No pude enviar la plantilla. Por favor contacta a un asesor.");
+ }
+ } catch (Exception $e) {
+ error_log("Error sending template: " . $e->getMessage());
+ $this->sendMessage($phoneNumber, "❌ Error al enviar plantilla.");
+ }
+ }
+
+ /**
+ * Envía un mensaje de texto
+ * @param string $phoneNumber
+ * @param string $message
+ * @return bool
+ */
+ private function sendMessage(string $phoneNumber, string $message): bool
+ {
+ try {
+ $result = $this->whatsappService->sendTextMessage($phoneNumber, $message);
+
+ if ($result) {
+ // Guardar en BD
+ $this->saveOutgoingMessage($phoneNumber, $message);
+ }
+
+ return $result;
+ } catch (Exception $e) {
+ error_log("Error sending message: " . $e->getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Guarda un mensaje saliente en la BD
+ * @param string $phoneNumber
+ * @param string $message
+ */
+ private function saveOutgoingMessage(string $phoneNumber, string $message): void
+ {
+ try {
+ $stmt = $this->db->prepare("
+ INSERT INTO conversations
+ (phone_number, message_type, message_text, direction, status, created_at)
+ VALUES (?, 'text', ?, 'outgoing', 'sent', NOW())
+ ");
+ $stmt->execute([$phoneNumber, $message]);
+ } catch (Exception $e) {
+ error_log("Error saving outgoing message: " . $e->getMessage());
+ }
+ }
+
+ /**
+ * Verifica si es el primer mensaje del usuario
+ * @param string $phoneNumber
+ * @return bool
+ */
+ private function isFirstMessage(string $phoneNumber): bool
+ {
+ $stmt = $this->db->prepare("
+ SELECT COUNT(*) as count
+ FROM conversations
+ WHERE phone_number = ?
+ AND direction = 'incoming'
+ ");
+ $stmt->execute([$phoneNumber]);
+ $result = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ return $result && $result['count'] == 1; // Solo tiene un mensaje (el actual)
+ }
+
+ /**
+ * Registra un log de la conversación
+ * @param string $phoneNumber
+ * @param string $message
+ */
+ private function logMessage(string $phoneNumber, string $message): void
+ {
+ error_log("[Bot - $phoneNumber] $message");
+ }
+}
diff --git a/services/BusinessHoursService.php b/services/BusinessHoursService.php
new file mode 100644
index 0000000..28dc4b1
--- /dev/null
+++ b/services/BusinessHoursService.php
@@ -0,0 +1,221 @@
+db = Database::getInstance();
+ }
+
+ /**
+ * Verifica si actualmente estamos en horario de atención
+ * @return bool
+ */
+ public function isBusinessHours(): bool
+ {
+ $now = new DateTime('now', new DateTimeZone('America/Bogota'));
+ $dayOfWeek = (int)$now->format('N'); // 1=Lunes, 7=Domingo
+ $currentTime = $now->format('H:i');
+
+ // Domingo (7) siempre cerrado
+ if ($dayOfWeek === 7) {
+ return false;
+ }
+
+ // Sábado (6)
+ if ($dayOfWeek === 6) {
+ $saturdayHours = $this->getBusinessHoursConfig('business_hours_saturday');
+ return $this->isTimeInRange($currentTime, $saturdayHours);
+ }
+
+ // Lunes a Viernes (1-5)
+ $weekdayHours = $this->getBusinessHoursConfig('business_hours_weekday');
+ return $this->isTimeInRange($currentTime, $weekdayHours);
+ }
+
+ /**
+ * Obtiene el mensaje apropiado según el horario
+ * @return string
+ */
+ public function getMessage(): string
+ {
+ if ($this->isBusinessHours()) {
+ return $this->getWelcomeMessage();
+ } else {
+ return $this->getOutOfHoursMessage();
+ }
+ }
+
+ /**
+ * Obtiene el mensaje de bienvenida
+ * @return string
+ */
+ private function getWelcomeMessage(): string
+ {
+ $stmt = $this->db->prepare("
+ SELECT config_value
+ FROM system_config
+ WHERE config_key = 'welcome_message'
+ LIMIT 1
+ ");
+ $stmt->execute();
+ $result = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if ($result) {
+ return $result['config_value'];
+ }
+
+ // Mensaje por defecto
+ return "¡Hola! 👋 Bienvenido(a) a nuestro servicio de WhatsApp.\n\nEscribe MENÚ para ver las opciones.";
+ }
+
+ /**
+ * Obtiene el mensaje fuera de horario
+ * @return string
+ */
+ private function getOutOfHoursMessage(): string
+ {
+ $stmt = $this->db->prepare("
+ SELECT config_value
+ FROM system_config
+ WHERE config_key = 'out_of_hours_message'
+ LIMIT 1
+ ");
+ $stmt->execute();
+ $result = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if ($result) {
+ return $result['config_value'];
+ }
+
+ // Mensaje por defecto
+ return "GRACIAS POR COMUNICARSE CON NOSOTROS ⏳\n\nEn este momento NO nos encontramos disponibles.\n\nTe responderemos lo antes posible en nuestro horario de atención.\n\n💻 Este es un mensaje automático\nGracias por tu comprensión 😊";
+ }
+
+ /**
+ * Obtiene la configuración de horarios
+ * @param string $key
+ * @return string
+ */
+ private function getBusinessHoursConfig(string $key): string
+ {
+ $stmt = $this->db->prepare("
+ SELECT config_value
+ FROM system_config
+ WHERE config_key = ?
+ LIMIT 1
+ ");
+ $stmt->execute([$key]);
+ $result = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ return $result ? $result['config_value'] : '';
+ }
+
+ /**
+ * Verifica si una hora está dentro de un rango
+ * @param string $currentTime Formato HH:mm
+ * @param string $rangesString Formato "HH:mm-HH:mm,HH:mm-HH:mm"
+ * @return bool
+ */
+ private function isTimeInRange(string $currentTime, string $rangesString): bool
+ {
+ if (empty($rangesString) || $rangesString === 'closed') {
+ return false;
+ }
+
+ // Dividir por comas para múltiples rangos (ej: "7:00-12:00,14:00-17:00")
+ $ranges = explode(',', $rangesString);
+
+ foreach ($ranges as $range) {
+ $times = explode('-', trim($range));
+ if (count($times) !== 2) {
+ continue;
+ }
+
+ $start = trim($times[0]);
+ $end = trim($times[1]);
+
+ if ($currentTime >= $start && $currentTime <= $end) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Obtiene información detallada del horario actual
+ * @return array
+ */
+ public function getCurrentStatus(): array
+ {
+ $now = new DateTime('now', new DateTimeZone('America/Bogota'));
+ $dayOfWeek = (int)$now->format('N');
+ $dayName = $this->getDayName($dayOfWeek);
+ $currentTime = $now->format('H:i');
+ $isOpen = $this->isBusinessHours();
+
+ return [
+ 'is_open' => $isOpen,
+ 'current_day' => $dayName,
+ 'current_time' => $currentTime,
+ 'day_of_week' => $dayOfWeek,
+ 'timezone' => 'America/Bogota'
+ ];
+ }
+
+ /**
+ * Obtiene el nombre del día en español
+ * @param int $dayNumber 1-7 (1=Lunes)
+ * @return string
+ */
+ private function getDayName(int $dayNumber): string
+ {
+ $days = [
+ 1 => 'Lunes',
+ 2 => 'Martes',
+ 3 => 'Miércoles',
+ 4 => 'Jueves',
+ 5 => 'Viernes',
+ 6 => 'Sábado',
+ 7 => 'Domingo'
+ ];
+
+ return $days[$dayNumber] ?? 'Desconocido';
+ }
+
+ /**
+ * Determina si se debe mostrar el mensaje de fuera de horario
+ * @param string $phoneNumber
+ * @return bool
+ */
+ public function shouldShowOutOfHoursMessage(string $phoneNumber): bool
+ {
+ if ($this->isBusinessHours()) {
+ return false;
+ }
+
+ // Verificar si ya se le envió el mensaje fuera de horario hoy
+ $stmt = $this->db->prepare("
+ SELECT id
+ FROM conversations
+ WHERE phone_number = ?
+ AND message_type = 'outgoing'
+ AND message_text LIKE '%NO nos encontramos disponibles%'
+ AND DATE(created_at) = CURDATE()
+ LIMIT 1
+ ");
+ $stmt->execute([$phoneNumber]);
+
+ // Si ya se le envió hoy, no enviar de nuevo
+ return $stmt->fetch() === false;
+ }
+}
diff --git a/services/ConversationStateService.php b/services/ConversationStateService.php
new file mode 100644
index 0000000..0fdb6f1
--- /dev/null
+++ b/services/ConversationStateService.php
@@ -0,0 +1,293 @@
+db = Database::getInstance();
+ }
+
+ /**
+ * Obtiene el estado actual de un usuario
+ * @param string $phoneNumber
+ * @return array|null
+ */
+ public function getState(string $phoneNumber): ?array
+ {
+ $stmt = $this->db->prepare("
+ SELECT * FROM user_states
+ WHERE phone_number = ?
+ ORDER BY id DESC
+ LIMIT 1
+ ");
+ $stmt->execute([$phoneNumber]);
+ $state = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if ($state && $state['state_data']) {
+ $state['state_data'] = json_decode($state['state_data'], true);
+ }
+
+ return $state ?: null;
+ }
+
+ /**
+ * Establece un nuevo estado para el usuario
+ * @param string $phoneNumber
+ * @param string $state
+ * @param array $data Datos adicionales del estado
+ * @return bool
+ */
+ public function setState(string $phoneNumber, string $state, array $data = []): bool
+ {
+ $stmt = $this->db->prepare("
+ INSERT INTO user_states (phone_number, state, state_data, created_at, updated_at)
+ VALUES (?, ?, ?, NOW(), NOW())
+ ON DUPLICATE KEY UPDATE
+ state = VALUES(state),
+ state_data = VALUES(state_data),
+ updated_at = NOW()
+ ");
+
+ $stateDataJson = !empty($data) ? json_encode($data, JSON_UNESCAPED_UNICODE) : null;
+
+ return $stmt->execute([$phoneNumber, $state, $stateDataJson]);
+ }
+
+ /**
+ * Actualiza los datos del estado actual sin cambiar el estado
+ * @param string $phoneNumber
+ * @param array $data
+ * @return bool
+ */
+ public function updateStateData(string $phoneNumber, array $data): bool
+ {
+ $currentState = $this->getState($phoneNumber);
+
+ if (!$currentState) {
+ return false;
+ }
+
+ $existingData = $currentState['state_data'] ?? [];
+ $mergedData = array_merge($existingData, $data);
+
+ $stmt = $this->db->prepare("
+ UPDATE user_states
+ SET state_data = ?, updated_at = NOW()
+ WHERE phone_number = ?
+ ");
+
+ return $stmt->execute([
+ json_encode($mergedData, JSON_UNESCAPED_UNICODE),
+ $phoneNumber
+ ]);
+ }
+
+ /**
+ * Obtiene datos específicos del estado
+ * @param string $phoneNumber
+ * @param string $key
+ * @return mixed
+ */
+ public function getStateData(string $phoneNumber, string $key)
+ {
+ $state = $this->getState($phoneNumber);
+
+ if (!$state || !isset($state['state_data'])) {
+ return null;
+ }
+
+ return $state['state_data'][$key] ?? null;
+ }
+
+ /**
+ * Limpia el estado del usuario
+ * @param string $phoneNumber
+ * @return bool
+ */
+ public function clearState(string $phoneNumber): bool
+ {
+ $stmt = $this->db->prepare("
+ DELETE FROM user_states
+ WHERE phone_number = ?
+ ");
+
+ return $stmt->execute([$phoneNumber]);
+ }
+
+ /**
+ * Verifica si el usuario está en un flujo específico
+ * @param string $phoneNumber
+ * @param string $state
+ * @return bool
+ */
+ public function isInState(string $phoneNumber, string $state): bool
+ {
+ $currentState = $this->getState($phoneNumber);
+ return $currentState && $currentState['state'] === $state;
+ }
+
+ /**
+ * Obtiene el contador de intentos
+ * @param string $phoneNumber
+ * @return int
+ */
+ public function getAttemptCount(string $phoneNumber): int
+ {
+ return (int)($this->getStateData($phoneNumber, 'attempt_count') ?? 0);
+ }
+
+ /**
+ * Incrementa el contador de intentos
+ * @param string $phoneNumber
+ * @return int Nuevo número de intentos
+ */
+ public function incrementAttempts(string $phoneNumber): int
+ {
+ $attempts = $this->getAttemptCount($phoneNumber) + 1;
+ $this->updateStateData($phoneNumber, ['attempt_count' => $attempts]);
+ return $attempts;
+ }
+
+ /**
+ * Resetea el contador de intentos
+ * @param string $phoneNumber
+ * @return bool
+ */
+ public function resetAttempts(string $phoneNumber): bool
+ {
+ return $this->updateStateData($phoneNumber, ['attempt_count' => 0]);
+ }
+
+ /**
+ * Guarda datos temporales del agendamiento
+ * @param string $phoneNumber
+ * @param array $appointmentData
+ * @return bool
+ */
+ public function saveAppointmentData(string $phoneNumber, array $appointmentData): bool
+ {
+ return $this->updateStateData($phoneNumber, [
+ 'appointment' => $appointmentData,
+ 'appointment_started_at' => date('Y-m-d H:i:s')
+ ]);
+ }
+
+ /**
+ * Obtiene datos del agendamiento en curso
+ * @param string $phoneNumber
+ * @return array|null
+ */
+ public function getAppointmentData(string $phoneNumber): ?array
+ {
+ return $this->getStateData($phoneNumber, 'appointment');
+ }
+
+ /**
+ * Verifica si el estado ha expirado (más de 30 minutos de inactividad)
+ * @param string $phoneNumber
+ * @return bool
+ */
+ public function isStateExpired(string $phoneNumber): bool
+ {
+ $state = $this->getState($phoneNumber);
+
+ if (!$state) {
+ return false;
+ }
+
+ $updatedAt = strtotime($state['updated_at']);
+ $now = time();
+ $diffMinutes = ($now - $updatedAt) / 60;
+
+ // Expirar después de 30 minutos
+ return $diffMinutes > 30;
+ }
+
+ /**
+ * Limpia estados expirados de todos los usuarios
+ * @return int Número de estados eliminados
+ */
+ public function cleanExpiredStates(): int
+ {
+ $stmt = $this->db->prepare("
+ DELETE FROM user_states
+ WHERE updated_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE)
+ AND state NOT IN (?, ?)
+ ");
+
+ $stmt->execute([
+ self::STATE_SCHEDULED,
+ self::STATE_WITH_ADVISOR
+ ]);
+
+ return $stmt->rowCount();
+ }
+
+ /**
+ * Obtiene el menú actual del usuario
+ * @param string $phoneNumber
+ * @return int|null ID del menú
+ */
+ public function getCurrentMenuId(string $phoneNumber): ?int
+ {
+ return $this->getStateData($phoneNumber, 'current_menu_id');
+ }
+
+ /**
+ * Establece el menú actual
+ * @param string $phoneNumber
+ * @param int $menuId
+ * @return bool
+ */
+ public function setCurrentMenu(string $phoneNumber, int $menuId): bool
+ {
+ return $this->updateStateData($phoneNumber, ['current_menu_id' => $menuId]);
+ }
+
+ /**
+ * Marca la conversación para transferir a asesor
+ * @param string $phoneNumber
+ * @param string $reason
+ * @return bool
+ */
+ public function markForTransfer(string $phoneNumber, string $reason = ''): bool
+ {
+ return $this->setState($phoneNumber, self::STATE_WITH_ADVISOR, [
+ 'transfer_reason' => $reason,
+ 'transferred_at' => date('Y-m-d H:i:s')
+ ]);
+ }
+
+ /**
+ * Verifica si el usuario está esperando asesor
+ * @param string $phoneNumber
+ * @return bool
+ */
+ public function isWaitingForAdvisor(string $phoneNumber): bool
+ {
+ return $this->isInState($phoneNumber, self::STATE_WITH_ADVISOR);
+ }
+}
diff --git a/services/MenuService.php b/services/MenuService.php
new file mode 100644
index 0000000..bca967d
--- /dev/null
+++ b/services/MenuService.php
@@ -0,0 +1,222 @@
+db = Database::getInstance();
+ }
+
+ /**
+ * Obtiene un menú por su ID
+ * @param int $menuId
+ * @return array|null
+ */
+ public function getMenu(int $menuId): ?array
+ {
+ $stmt = $this->db->prepare("
+ SELECT * FROM menus
+ WHERE id = ?
+ AND status = 'active'
+ LIMIT 1
+ ");
+ $stmt->execute([$menuId]);
+ $menu = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if (!$menu) {
+ return null;
+ }
+
+ // Obtener opciones del menú
+ $menu['options'] = $this->getMenuOptions($menuId);
+
+ return $menu;
+ }
+
+ /**
+ * Obtiene un menú por su clave
+ * @param string $menuKey
+ * @return array|null
+ */
+ public function getMenuByKey(string $menuKey): ?array
+ {
+ $stmt = $this->db->prepare("
+ SELECT * FROM menus
+ WHERE menu_key = ?
+ AND status = 'active'
+ LIMIT 1
+ ");
+ $stmt->execute([$menuKey]);
+ $menu = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if (!$menu) {
+ return null;
+ }
+
+ $menu['options'] = $this->getMenuOptions($menu['id']);
+
+ return $menu;
+ }
+
+ /**
+ * Obtiene las opciones de un menú
+ * @param int $menuId
+ * @return array
+ */
+ public function getMenuOptions(int $menuId): array
+ {
+ $stmt = $this->db->prepare("
+ SELECT * FROM menu_options
+ WHERE menu_id = ?
+ AND is_active = 1
+ ORDER BY option_order ASC
+ ");
+ $stmt->execute([$menuId]);
+
+ return $stmt->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ /**
+ * Obtiene el ID del menú principal
+ * @return int|null
+ */
+ public function getMainMenuId(): ?int
+ {
+ $stmt = $this->db->prepare("
+ SELECT id FROM menus
+ WHERE menu_key = 'lab_menu_principal'
+ AND status = 'active'
+ LIMIT 1
+ ");
+ $stmt->execute();
+ $result = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ return $result ? (int)$result['id'] : null;
+ }
+
+ /**
+ * Procesa una opción seleccionada del menú
+ * @param int $menuId
+ * @param string $optionKey
+ * @return array|null
+ */
+ public function processMenuOption(int $menuId, string $optionKey): ?array
+ {
+ $stmt = $this->db->prepare("
+ SELECT * FROM menu_options
+ WHERE menu_id = ?
+ AND option_key = ?
+ AND is_active = 1
+ LIMIT 1
+ ");
+ $stmt->execute([$menuId, $optionKey]);
+ $option = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if (!$option) {
+ return null;
+ }
+
+ return [
+ 'type' => $option['response_type'],
+ 'text' => $option['response_text'],
+ 'next_menu_id' => $option['next_menu_id']
+ ];
+ }
+
+ /**
+ * Formatea un menú con su welcome_message y opciones
+ * @param int $menuId
+ * @return string
+ */
+ public function formatMenuMessage(int $menuId): string
+ {
+ $menu = $this->getMenu($menuId);
+
+ if (!$menu) {
+ return "❌ Menú no disponible.";
+ }
+
+ $message = $menu['welcome_message'];
+
+ // Si el welcome_message no incluye las opciones, agregarlas
+ if (!empty($menu['options']) && strpos($message, '1️⃣') === false) {
+ $message .= "\n\n";
+ foreach ($menu['options'] as $option) {
+ $emoji = $this->getNumberEmoji($option['option_key']);
+ $message .= "$emoji {$option['option_text']}\n";
+ }
+ }
+
+ return $message;
+ }
+
+ /**
+ * Obtiene el emoji de número para una opción
+ * @param string $number
+ * @return string
+ */
+ private function getNumberEmoji(string $number): string
+ {
+ $emojis = [
+ '0' => '0️⃣',
+ '1' => '1️⃣',
+ '2' => '2️⃣',
+ '3' => '3️⃣',
+ '4' => '4️⃣',
+ '5' => '5️⃣',
+ '6' => '6️⃣',
+ '7' => '7️⃣',
+ '8' => '8️⃣',
+ '9' => '9️⃣'
+ ];
+
+ return $emojis[$number] ?? $number;
+ }
+
+ /**
+ * Obtiene todos los menús activos
+ * @return array
+ */
+ public function getAllMenus(): array
+ {
+ $stmt = $this->db->prepare("
+ SELECT m.*,
+ (SELECT COUNT(*) FROM menu_options WHERE menu_id = m.id AND is_active = 1) as options_count
+ FROM menus m
+ WHERE m.status = 'active'
+ ORDER BY m.created_at ASC
+ ");
+ $stmt->execute();
+
+ return $stmt->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ /**
+ * Obtiene el menú padre de un menú
+ * @param int $menuId
+ * @return array|null
+ */
+ public function getParentMenu(int $menuId): ?array
+ {
+ $stmt = $this->db->prepare("
+ SELECT parent_menu_id FROM menus
+ WHERE id = ?
+ LIMIT 1
+ ");
+ $stmt->execute([$menuId]);
+ $result = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if (!$result || !$result['parent_menu_id']) {
+ return null;
+ }
+
+ return $this->getMenu($result['parent_menu_id']);
+ }
+}
diff --git a/services/NLPService.php b/services/NLPService.php
new file mode 100644
index 0000000..8d5a207
--- /dev/null
+++ b/services/NLPService.php
@@ -0,0 +1,377 @@
+db = Database::getInstance();
+ }
+
+ /**
+ * Procesa un mensaje y encuentra la mejor respuesta automática
+ * @param string $message
+ * @param string $phoneNumber
+ * @return array|null ['type' => 'autoresponse|menu|transfer', 'data' => array]
+ */
+ public function processMessage(string $message, string $phoneNumber): ?array
+ {
+ // Normalizar mensaje
+ $normalizedMessage = $this->normalizeText($message);
+
+ // 1. Verificar si es una opción de menú numérica
+ if (is_numeric(trim($message))) {
+ return $this->processMenuOption($message, $phoneNumber);
+ }
+
+ // 2. Buscar respuesta automática por palabra clave
+ $response = $this->findAutoResponse($normalizedMessage);
+
+ if ($response) {
+ return $this->formatResponse($response);
+ }
+
+ // 3. Si no encuentra nada, devolver respuesta por defecto
+ return $this->getDefaultResponse();
+ }
+
+ /**
+ * Normaliza el texto para comparación
+ * @param string $text
+ * @return string
+ */
+ private function normalizeText(string $text): string
+ {
+ // Convertir a minúsculas
+ $text = mb_strtolower($text, 'UTF-8');
+
+ // Remover acentos
+ $text = $this->removeAccents($text);
+
+ // Remover emojis y caracteres especiales (excepto espacios y comas)
+ $text = preg_replace('/[^\p{L}\p{N}\s,]/u', '', $text);
+
+ // Remover espacios extras
+ $text = preg_replace('/\s+/', ' ', $text);
+
+ return trim($text);
+ }
+
+ /**
+ * Remueve acentos de un texto
+ * @param string $text
+ * @return string
+ */
+ private function removeAccents(string $text): string
+ {
+ $accents = [
+ 'á' => 'a', 'é' => 'e', 'í' => 'i', 'ó' => 'o', 'ú' => 'u',
+ 'Á' => 'a', 'É' => 'e', 'Í' => 'i', 'Ó' => 'o', 'Ú' => 'u',
+ 'ñ' => 'n', 'Ñ' => 'n', 'ü' => 'u', 'Ü' => 'u'
+ ];
+
+ return strtr($text, $accents);
+ }
+
+ /**
+ * Busca la mejor respuesta automática para el mensaje
+ * @param string $normalizedMessage
+ * @return array|null
+ */
+ private function findAutoResponse(string $normalizedMessage): ?array
+ {
+ // Obtener todas las respuestas automáticas activas ordenadas por prioridad
+ $stmt = $this->db->prepare("
+ SELECT * FROM autoresponses
+ WHERE is_active = 1
+ AND trigger_type != 'welcome'
+ ORDER BY priority DESC, id ASC
+ ");
+ $stmt->execute();
+ $autoResponses = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+ foreach ($autoResponses as $response) {
+ if ($this->matchesTrigger($normalizedMessage, $response)) {
+ return $response;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Verifica si un mensaje coincide con un trigger
+ * @param string $message
+ * @param array $response
+ * @return bool
+ */
+ private function matchesTrigger(string $message, array $response): bool
+ {
+ $triggerType = $response['trigger_type'];
+ $triggerValue = $response['trigger_value'];
+
+ switch ($triggerType) {
+ case 'keyword':
+ return $this->matchesKeywords($message, $triggerValue);
+
+ case 'contains':
+ return $this->containsPhrase($message, $triggerValue);
+
+ case 'exact':
+ return $this->exactMatch($message, $triggerValue);
+
+ case 'default':
+ return true; // Siempre coincide (pero tiene prioridad baja)
+
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Verifica si el mensaje contiene alguna palabra clave
+ * @param string $message
+ * @param string $keywordsString Palabras separadas por comas
+ * @return bool
+ */
+ private function matchesKeywords(string $message, string $keywordsString): bool
+ {
+ if (empty($keywordsString)) {
+ return false;
+ }
+
+ $keywords = array_map('trim', explode(',', $keywordsString));
+ $keywords = array_map([$this, 'normalizeText'], $keywords);
+
+ $messageWords = explode(' ', $message);
+
+ foreach ($keywords as $keyword) {
+ // Verificar si la palabra clave completa está en el mensaje
+ if (strpos($message, $keyword) !== false) {
+ return true;
+ }
+
+ // O si alguna palabra del mensaje coincide exactamente
+ if (in_array($keyword, $messageWords)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Verifica si el mensaje contiene una frase
+ * @param string $message
+ * @param string $phrase
+ * @return bool
+ */
+ private function containsPhrase(string $message, string $phrase): bool
+ {
+ $normalizedPhrase = $this->normalizeText($phrase);
+ return strpos($message, $normalizedPhrase) !== false;
+ }
+
+ /**
+ * Verifica coincidencia exacta
+ * @param string $message
+ * @param string $exact
+ * @return bool
+ */
+ private function exactMatch(string $message, string $exact): bool
+ {
+ $normalizedExact = $this->normalizeText($exact);
+ return $message === $normalizedExact;
+ }
+
+ /**
+ * Procesa una opción numérica de menú
+ * @param string $option
+ * @param string $phoneNumber
+ * @return array|null
+ */
+ private function processMenuOption(string $option, string $phoneNumber): ?array
+ {
+ $stateService = new ConversationStateService();
+ $currentMenuId = $stateService->getCurrentMenuId($phoneNumber);
+
+ if (!$currentMenuId) {
+ // Si no hay menú actual, buscar el menú principal
+ $stmt = $this->db->prepare("
+ SELECT id FROM menus
+ WHERE menu_key = 'lab_menu_principal'
+ AND status = 'active'
+ LIMIT 1
+ ");
+ $stmt->execute();
+ $menu = $stmt->fetch(PDO::FETCH_ASSOC);
+ $currentMenuId = $menu ? $menu['id'] : null;
+ }
+
+ if (!$currentMenuId) {
+ return null;
+ }
+
+ // Buscar la opción en el menú actual
+ $stmt = $this->db->prepare("
+ SELECT * FROM menu_options
+ WHERE menu_id = ?
+ AND option_key = ?
+ AND is_active = 1
+ LIMIT 1
+ ");
+ $stmt->execute([$currentMenuId, trim($option)]);
+ $menuOption = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if (!$menuOption) {
+ return null;
+ }
+
+ return $this->formatMenuOption($menuOption);
+ }
+
+ /**
+ * Formatea una respuesta automática
+ * @param array $response
+ * @return array
+ */
+ private function formatResponse(array $response): array
+ {
+ $result = [
+ 'type' => $response['response_type'],
+ 'priority' => $response['priority']
+ ];
+
+ switch ($response['response_type']) {
+ case 'text':
+ $result['message'] = $response['response_text'];
+ break;
+
+ case 'menu':
+ $result['menu_id'] = $response['menu_id'];
+ break;
+
+ case 'template':
+ $result['template_name'] = $response['template_name'] ?? null;
+ break;
+
+ case 'transfer':
+ $result['message'] = $response['response_text'];
+ $result['transfer'] = true;
+ break;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Formatea una opción de menú
+ * @param array $menuOption
+ * @return array
+ */
+ private function formatMenuOption(array $menuOption): array
+ {
+ $result = [
+ 'type' => $menuOption['response_type']
+ ];
+
+ switch ($menuOption['response_type']) {
+ case 'text':
+ $result['message'] = $menuOption['response_text'];
+ break;
+
+ case 'menu':
+ $result['menu_id'] = $menuOption['next_menu_id'];
+ break;
+
+ case 'transfer':
+ $result['message'] = $menuOption['response_text'];
+ $result['transfer'] = true;
+ break;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Obtiene la respuesta por defecto
+ * @return array
+ */
+ private function getDefaultResponse(): array
+ {
+ $stmt = $this->db->prepare("
+ SELECT * FROM autoresponses
+ WHERE trigger_type = 'default'
+ AND is_active = 1
+ ORDER BY priority DESC
+ LIMIT 1
+ ");
+ $stmt->execute();
+ $response = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if ($response) {
+ return $this->formatResponse($response);
+ }
+
+ // Respuesta fallback hardcoded
+ return [
+ 'type' => 'text',
+ 'message' => "❓ No he comprendido tu mensaje.\n\nEscribe MENÚ para ver las opciones disponibles."
+ ];
+ }
+
+ /**
+ * Detecta el sentimiento del mensaje (básico)
+ * @param string $message
+ * @return string positive|negative|neutral
+ */
+ public function detectSentiment(string $message): string
+ {
+ $normalized = $this->normalizeText($message);
+
+ // Palabras positivas
+ $positiveWords = ['gracias', 'excelente', 'perfecto', 'bien', 'bueno', 'genial', 'ok', 'vale'];
+
+ // Palabras negativas / frustración
+ $negativeWords = ['no funciona', 'problema', 'error', 'mal', 'ayuda', 'no entiendo', 'dificil', 'complicado'];
+
+ $positiveCount = 0;
+ $negativeCount = 0;
+
+ foreach ($positiveWords as $word) {
+ if (strpos($normalized, $word) !== false) {
+ $positiveCount++;
+ }
+ }
+
+ foreach ($negativeWords as $word) {
+ if (strpos($normalized, $word) !== false) {
+ $negativeCount++;
+ }
+ }
+
+ if ($negativeCount > $positiveCount) {
+ return 'negative';
+ } elseif ($positiveCount > 0) {
+ return 'positive';
+ }
+
+ return 'neutral';
+ }
+
+ /**
+ * Determina si el mensaje indica frustración (para escalar a asesor)
+ * @param string $message
+ * @return bool
+ */
+ public function detectsFrustration(string $message): bool
+ {
+ return $this->detectSentiment($message) === 'negative';
+ }
+}
diff --git a/services/PatientDataValidator.php b/services/PatientDataValidator.php
new file mode 100644
index 0000000..c652fe1
--- /dev/null
+++ b/services/PatientDataValidator.php
@@ -0,0 +1,315 @@
+ bool, 'errors' => array, 'data' => array]
+ */
+ public static function validateAppointmentData(array $data): array
+ {
+ $errors = [];
+ $validData = [];
+
+ // Nombre completo
+ if (empty($data['nombre'])) {
+ $errors[] = "Falta el nombre completo";
+ } elseif (!self::validateFullName($data['nombre'])) {
+ $errors[] = "El nombre debe tener al menos nombre y apellido";
+ } else {
+ $validData['nombre'] = self::sanitizeName($data['nombre']);
+ }
+
+ // Documento
+ if (empty($data['documento'])) {
+ $errors[] = "Falta el número de documento";
+ } elseif (!self::validateDocument($data['documento'])) {
+ $errors[] = "El documento debe tener entre 6 y 11 dígitos";
+ } else {
+ $validData['documento'] = self::sanitizeDocument($data['documento']);
+ }
+
+ // Dirección
+ if (empty($data['direccion'])) {
+ $errors[] = "Falta la dirección completa";
+ } elseif (strlen($data['direccion']) < 10) {
+ $errors[] = "La dirección debe ser más específica (mínimo 10 caracteres)";
+ } else {
+ $validData['direccion'] = trim($data['direccion']);
+ }
+
+ // Teléfono
+ if (empty($data['telefono'])) {
+ $errors[] = "Falta el número de celular";
+ } elseif (!self::validatePhone($data['telefono'])) {
+ $errors[] = "El número de celular debe tener 10 dígitos y comenzar con 3";
+ } else {
+ $validData['telefono'] = self::sanitizePhone($data['telefono']);
+ }
+
+ // Email
+ if (empty($data['email'])) {
+ $errors[] = "Falta el correo electrónico";
+ } elseif (!self::validateEmail($data['email'])) {
+ $errors[] = "El correo electrónico no es válido";
+ } else {
+ $validData['email'] = strtolower(trim($data['email']));
+ }
+
+ // Tipo (particular/seguro)
+ if (empty($data['tipo'])) {
+ $errors[] = "Falta indicar si es particular o por seguro";
+ } else {
+ $tipo = strtolower(trim($data['tipo']));
+ if (strpos($tipo, 'particular') !== false) {
+ $validData['tipo'] = 'particular';
+ } elseif (strpos($tipo, 'seguro') !== false || strpos($tipo, 'eps') !== false) {
+ $validData['tipo'] = 'seguro';
+ // Extraer nombre del seguro si lo menciona
+ $validData['nombre_seguro'] = self::extractInsuranceName($data['tipo']);
+ } else {
+ $errors[] = "Debe indicar 'particular' o 'seguro'";
+ }
+ }
+
+ return [
+ 'valid' => empty($errors),
+ 'errors' => $errors,
+ 'data' => $validData
+ ];
+ }
+
+ /**
+ * Valida un nombre completo
+ * @param string $name
+ * @return bool
+ */
+ public static function validateFullName(string $name): bool
+ {
+ $name = trim($name);
+
+ // Debe tener al menos 2 palabras
+ $words = preg_split('/\s+/', $name);
+ if (count($words) < 2) {
+ return false;
+ }
+
+ // Solo debe contener letras y espacios
+ return preg_match('/^[a-záéíóúñA-ZÁÉÍÓÚÑ\s]+$/u', $name);
+ }
+
+ /**
+ * Valida un número de documento
+ * @param string $document
+ * @return bool
+ */
+ public static function validateDocument(string $document): bool
+ {
+ $doc = preg_replace('/[^0-9]/', '', $document);
+ $length = strlen($doc);
+
+ return $length >= 6 && $length <= 11;
+ }
+
+ /**
+ * Valida un número de teléfono colombiano
+ * @param string $phone
+ * @return bool
+ */
+ public static function validatePhone(string $phone): bool
+ {
+ $phone = preg_replace('/[^0-9]/', '', $phone);
+
+ // Debe tener 10 dígitos y comenzar con 3
+ return strlen($phone) === 10 && $phone[0] === '3';
+ }
+
+ /**
+ * Valida un correo electrónico
+ * @param string $email
+ * @return bool
+ */
+ public static function validateEmail(string $email): bool
+ {
+ return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
+ }
+
+ /**
+ * Sanitiza un nombre
+ * @param string $name
+ * @return string
+ */
+ public static function sanitizeName(string $name): string
+ {
+ // Convertir a formato título (Primera Letra Mayúscula)
+ $name = mb_convert_case(trim($name), MB_CASE_TITLE, 'UTF-8');
+
+ // Remover espacios extras
+ $name = preg_replace('/\s+/', ' ', $name);
+
+ return $name;
+ }
+
+ /**
+ * Sanitiza un documento
+ * @param string $document
+ * @return string
+ */
+ public static function sanitizeDocument(string $document): string
+ {
+ return preg_replace('/[^0-9]/', '', $document);
+ }
+
+ /**
+ * Sanitiza un teléfono
+ * @param string $phone
+ * @return string
+ */
+ public static function sanitizePhone(string $phone): string
+ {
+ $phone = preg_replace('/[^0-9]/', '', $phone);
+
+ // Si tiene 13 dígitos y empieza con 57, quitar el 57
+ if (strlen($phone) === 12 && substr($phone, 0, 2) === '57') {
+ $phone = substr($phone, 2);
+ }
+
+ return $phone;
+ }
+
+ /**
+ * Extrae el nombre del seguro del texto
+ * @param string $text
+ * @return string|null
+ */
+ private static function extractInsuranceName(string $text): ?string
+ {
+ $insurances = [
+ 'colsanitas' => 'Colsanitas',
+ 'sanitas' => 'Sanitas',
+ 'sura' => 'Sura',
+ 'compensar' => 'Compensar',
+ 'salud total' => 'Salud Total',
+ 'famisanar' => 'Famisanar',
+ 'nueva eps' => 'Nueva EPS',
+ 'coomeva' => 'Coomeva'
+ ];
+
+ $textLower = strtolower($text);
+
+ foreach ($insurances as $key => $name) {
+ if (strpos($textLower, $key) !== false) {
+ return $name;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Parsea datos del paciente desde un mensaje de texto
+ * @param string $message
+ * @return array
+ */
+ public static function parsePatientDataFromMessage(string $message): array
+ {
+ $data = [];
+
+ // Dividir por líneas
+ $lines = explode("\n", $message);
+
+ foreach ($lines as $line) {
+ $line = trim($line);
+
+ if (empty($line)) {
+ continue;
+ }
+
+ // Detectar nombre (primera línea larga con letras)
+ if (empty($data['nombre']) && preg_match('/^[a-záéíóúñA-ZÁÉÍÓÚÑ\s]{5,}$/u', $line)) {
+ $data['nombre'] = $line;
+ continue;
+ }
+
+ // Detectar documento (números de 6-11 dígitos)
+ if (empty($data['documento']) && preg_match('/(\d{6,11})/', $line, $matches)) {
+ $data['documento'] = $matches[1];
+ continue;
+ }
+
+ // Detectar teléfono (10 dígitos comenzando con 3)
+ if (empty($data['telefono']) && preg_match('/(3\d{9})/', $line, $matches)) {
+ $data['telefono'] = $matches[1];
+ continue;
+ }
+
+ // Detectar email
+ if (empty($data['email']) && preg_match('/([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z]+)/', $line, $matches)) {
+ $data['email'] = $matches[1];
+ continue;
+ }
+
+ // Detectar dirección (contiene calle, carrera, etc.)
+ if (empty($data['direccion']) && preg_match('/(calle|carrera|cra|cl|diagonal|transversal|avenida|av)/i', $line)) {
+ $data['direccion'] = $line;
+ continue;
+ }
+
+ // Detectar tipo (particular/seguro)
+ if (empty($data['tipo']) && preg_match('/(particular|seguro|eps)/i', $line)) {
+ $data['tipo'] = $line;
+ continue;
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Genera un mensaje con los errores de validación
+ * @param array $errors
+ * @return string
+ */
+ public static function formatValidationErrors(array $errors): string
+ {
+ $message = "⚠️ Por favor corrige los siguientes datos:\n\n";
+
+ foreach ($errors as $error) {
+ $message .= "❌ $error\n";
+ }
+
+ $message .= "\nEnvía la información completa de nuevo.";
+
+ return $message;
+ }
+
+ /**
+ * Genera un resumen de los datos validados
+ * @param array $data
+ * @return string
+ */
+ public static function formatDataSummary(array $data): string
+ {
+ $message = "📋 DATOS RECIBIDOS:\n\n";
+ $message .= "👤 Nombre: {$data['nombre']}\n";
+ $message .= "🆔 Documento: {$data['documento']}\n";
+ $message .= "📍 Dirección: {$data['direccion']}\n";
+ $message .= "📱 Teléfono: {$data['telefono']}\n";
+ $message .= "📧 Email: {$data['email']}\n";
+ $message .= "💳 Tipo: {$data['tipo']}";
+
+ if (isset($data['nombre_seguro'])) {
+ $message .= " ({$data['nombre_seguro']})";
+ }
+
+ $message .= "\n\n¿Los datos son correctos?\nResponde: *SÍ* o *NO*";
+
+ return $message;
+ }
+}
diff --git a/setup_laboratorio_ximena.sql b/setup_laboratorio_ximena.sql
new file mode 100644
index 0000000..e04859d
--- /dev/null
+++ b/setup_laboratorio_ximena.sql
@@ -0,0 +1,732 @@
+-- =====================================================
+-- SETUP COMPLETO: LABORATORIO XIMENA CAICEDO
+-- Sistema de Chatbot WhatsApp con Flujos Completos
+-- Fecha: 13 de enero de 2026
+-- =====================================================
+
+-- Limpiar datos previos (opcional)
+-- DELETE FROM menu_options WHERE menu_id IN (SELECT id FROM menus WHERE menu_key LIKE 'lab_%');
+-- DELETE FROM menus WHERE menu_key LIKE 'lab_%';
+-- DELETE FROM autoresponses WHERE trigger_type != 'system';
+
+-- =====================================================
+-- 1. MENÚ PRINCIPAL
+-- =====================================================
+
+INSERT INTO menus (name, menu_key, description, welcome_message, status, created_at) VALUES
+(
+ 'Menú Principal - Lab Ximena Caicedo',
+ 'lab_menu_principal',
+ 'Menú principal del laboratorio con todas las opciones de servicio',
+ '¡Hola! 👋 Bienvenido(a) a Laboratorio Ximena Caicedo
+
+Por favor, selecciona una opción escribiendo el número:
+
+1️⃣ Agendar toma a domicilio
+2️⃣ Consultar resultados
+3️⃣ Información sobre exámenes
+4️⃣ Ubicación y horarios
+5️⃣ Hablar con un asesor
+
+*Horario de atención:*
+📅 Lunes a viernes: 7:00 AM - 12:00 PM y 2:00 PM - 5:00 PM
+📅 Sábados: 8:00 AM - 11:00 AM',
+ 'active',
+ NOW()
+);
+
+-- Obtener el ID del menú principal
+SET @menu_principal_id = LAST_INSERT_ID();
+
+-- Opciones del Menú Principal
+INSERT INTO menu_options (menu_id, option_key, option_order, option_text, response_type, response_text, next_menu_id, is_active, created_at) VALUES
+(
+ @menu_principal_id,
+ '1',
+ 1,
+ 'Agendar toma a domicilio',
+ 'text',
+ '📍 TOMA DE MUESTRAS A DOMICILIO
+
+Para agendar tu domicilio, necesitamos la siguiente información en UN SOLO MENSAJE:
+
+📄 Enviar una foto clara de la orden médica
+
+Datos del paciente:
+✓ Nombre completo:
+✓ Número de documento:
+✓ Foto del documento de identidad:
+✓ Dirección completa:
+✓ Número de celular:
+✓ Correo electrónico:
+✓ Indicar si es por algún seguro o particular
+
+*IMPORTANTE:*
+• Se atiende por orden de llegada
+• Enviar solicitud solo una vez
+• Pronto te daremos respuesta
+
+💵 Valor del domicilio: $27.000',
+ NULL,
+ 1,
+ NOW()
+),
+(
+ @menu_principal_id,
+ '2',
+ 2,
+ 'Consultar resultados',
+ 'text',
+ '📊 CONSULTA DE RESULTADOS
+
+Para consultar tus resultados, sigue estos pasos:
+
+1️⃣ Ingresa a la página web:
+http://www.resultadoslab.com.co/resultadoslab/vistas/login.php?id=eGltY2FpY2Vkbw==
+
+2️⃣ Credenciales de acceso:
+• Usuario: Tu número de documento
+• Clave: Los últimos 4 dígitos de tu documento
+
+3️⃣ Da clic en los cuadros rojo y naranja que aparecen en opciones
+
+4️⃣ Descarga los archivos PDF
+
+*NOTA:* Se recomienda descargar desde un computador
+
+⚠️ Estamos actualizando nuestra página web. Gracias por tu comprensión.
+
+¿Tienes problemas para descargar tus resultados?
+Responde: *AYUDA RESULTADOS*',
+ NULL,
+ 1,
+ NOW()
+),
+(
+ @menu_principal_id,
+ '3',
+ 3,
+ 'Información sobre exámenes',
+ 'menu',
+ NULL,
+ NULL, -- Se actualizará después de crear el submenú
+ 1,
+ NOW()
+),
+(
+ @menu_principal_id,
+ '4',
+ 4,
+ 'Ubicación y horarios',
+ 'text',
+ '📍 UBICACIÓN Y HORARIOS
+
+*DIRECCIÓN:*
+Calle 21 #0-26, Barrio Blanco
+Cúcuta, Norte de Santander
+
+📌 Ver en Google Maps:
+https://maps.google.com/maps/search/Laboratorio%20Ximena%20Caicedo/@7.877200126647949,-72.49710083007812,17z?hl=es
+
+*HORARIOS DE ATENCIÓN:*
+
+🏥 Toma de muestras en laboratorio:
+📅 Lunes a viernes: 6:15 AM - 3:00 PM
+📅 Sábados: Por orden de llegada
+
+📞 Atención telefónica/WhatsApp:
+📅 Lunes a viernes: 7:00 AM - 12:00 PM y 2:00 PM - 5:00 PM
+📅 Sábados: 8:00 AM - 11:00 AM
+
+🏠 Tomas a domicilio:
+📅 Lunes a viernes: 5:00 AM - 8:30 AM
+💵 Valor: $27.000
+
+*FORMAS DE PAGO:*
+💵 Efectivo
+💳 Tarjeta débito
+🏥 Seguros médicos
+
+¿Necesitas agendar? Escribe MENÚ',
+ NULL,
+ 1,
+ NOW()
+),
+(
+ @menu_principal_id,
+ '5',
+ 5,
+ 'Hablar con un asesor',
+ 'transfer',
+ '👤 SOLICITUD DE ASESOR
+
+Un momento por favor, estamos conectando tu consulta con uno de nuestros asesores.
+
+En breve te atenderemos.
+
+*Tu consulta es importante para nosotros* 💙',
+ NULL,
+ 1,
+ NOW()
+);
+
+-- =====================================================
+-- 2. SUBMENÚ DE EXÁMENES
+-- =====================================================
+
+INSERT INTO menus (name, menu_key, description, welcome_message, parent_menu_id, status, created_at) VALUES
+(
+ 'Información de Exámenes',
+ 'lab_menu_examenes',
+ 'Submenú con información detallada de exámenes',
+ '🔬 INFORMACIÓN DE EXÁMENES
+
+Selecciona el tipo de información que necesitas:
+
+1️⃣ Pruebas de embarazo
+2️⃣ Prueba de paternidad
+3️⃣ Helicobacter pylori (en aliento)
+4️⃣ Orina de 24 horas
+5️⃣ Solicitar cotización
+0️⃣ Volver al menú principal',
+ @menu_principal_id,
+ 'active',
+ NOW()
+);
+
+SET @menu_examenes_id = LAST_INSERT_ID();
+
+-- Actualizar la opción 3 del menú principal para que apunte a este submenú
+UPDATE menu_options
+SET next_menu_id = @menu_examenes_id
+WHERE menu_id = @menu_principal_id AND option_key = '3';
+
+-- Opciones del Submenú de Exámenes
+INSERT INTO menu_options (menu_id, option_key, option_order, option_text, response_type, response_text, next_menu_id, is_active, created_at) VALUES
+(
+ @menu_examenes_id,
+ '1',
+ 1,
+ 'Pruebas de embarazo',
+ 'text',
+ '🤰 PRUEBAS DE EMBARAZO EN SANGRE
+
+Ofrecemos dos tipos:
+
+1️⃣ *Prueba Cualitativa (Positivo/Negativo)*
+💵 Valor: $28.000
+📅 Requisito: Mínimo 1 semana de retraso menstrual
+
+2️⃣ *Prueba Cuantitativa (Con valor de HCG)*
+💵 Valor: $61.000
+📊 Esta prueba da un valor que se interpreta para saber las semanas de embarazo
+
+🕐 Horario: 6:15 AM - 3:00 PM (Lunes a viernes)
+⏱️ Tiempo de resultado: 24-72 horas
+
+¿Deseas agendar? Escribe: *AGENDAR EMBARAZO*',
+ NULL,
+ 1,
+ NOW()
+),
+(
+ @menu_examenes_id,
+ '2',
+ 2,
+ 'Prueba de paternidad',
+ 'text',
+ '👨👩👧 PRUEBA DE PATERNIDAD
+
+📍 Ubicación: Calle 21 #0-26, Barrio Blanco, Cúcuta
+https://maps.google.com/maps/search/Laboratorio%20Ximena%20Caicedo/@7.877200126647949,-72.49710083007812,17z?hl=es
+
+*DETALLES DEL EXAMEN:*
+🧬 Muestra: Se realiza en saliva
+👥 Deben asistir: Presunto padre, madre y el menor
+💵 Valor: $760.000
+📅 Resultados: 12 a 15 días hábiles
+🕐 Horario: Lunes a viernes de 6:15 AM a 3:00 PM
+📝 Se toma por orden de llegada (sin cita previa)
+
+⚠️ *NO se realiza a domicilio*
+Deben acercarse al laboratorio.
+
+¿Necesitas más información? Escribe *ASESOR*',
+ NULL,
+ 1,
+ NOW()
+),
+(
+ @menu_examenes_id,
+ '3',
+ 3,
+ 'Helicobacter pylori (en aliento)',
+ 'text',
+ '🫁 HELICOBACTER PYLORI EN ALIENTO
+
+*PREPARACIÓN IMPORTANTE:*
+
+⏰ Requisitos previos:
+• 2 semanas sin tomar medicamentos para el estómago
+• Sin protectores gástricos
+• Sin probióticos
+• Sin antibióticos
+• Sin cualquier medicamento relacionado con el estómago
+
+📍 Día del examen:
+• Debe venir en AYUNAS
+• NO cepillarse los dientes
+• Llegar entre 6:15 AM - 9:30 AM
+
+📅 Disponible de lunes a viernes
+
+¿Deseas cotización? Escribe: *COTIZAR HELICOBACTER*',
+ NULL,
+ 1,
+ NOW()
+),
+(
+ @menu_examenes_id,
+ '4',
+ 4,
+ 'Orina de 24 horas',
+ 'text',
+ '💧 INSTRUCCIONES: ORINA DE 24 HORAS
+
+*PASO A PASO:*
+
+1️⃣ *PRIMERA ORINA DE LA MAÑANA:*
+ Deséchela (NO la recoja)
+
+2️⃣ *A PARTIR DE LA SEGUNDA ORINA:*
+ Recoja TODA la orina del día
+
+3️⃣ *DURANTE LA NOCHE:*
+ Si se levanta a orinar, también debe recogerla
+
+4️⃣ *ALMACENAMIENTO:*
+ ⚠️ IMPORTANTE: Mantener la orina REFRIGERADA todo el tiempo para evitar contaminación
+
+5️⃣ *AL DÍA SIGUIENTE:*
+ Llevar el recipiente completo al laboratorio
+
+📍 Horario de recepción: 6:15 AM - 3:00 PM
+
+¿Tienes dudas? Escribe *ASESOR*',
+ NULL,
+ 1,
+ NOW()
+),
+(
+ @menu_examenes_id,
+ '5',
+ 5,
+ 'Solicitar cotización',
+ 'text',
+ '💰 SOLICITAR COTIZACIÓN
+
+Para cotizar tus exámenes, envía:
+
+📄 Foto CLARA de la orden médica
+✓ Indica si es particular o por seguro
+✓ Si es por seguro, indica cuál
+
+Te responderemos con:
+• Valor de cada examen
+• Valor total
+• Indicaciones de preparación
+
+⏱️ Respuesta en horario de atención',
+ NULL,
+ 1,
+ NOW()
+),
+(
+ @menu_examenes_id,
+ '0',
+ 0,
+ 'Volver al menú principal',
+ 'menu',
+ NULL,
+ @menu_principal_id,
+ 1,
+ NOW()
+);
+
+-- =====================================================
+-- 3. RESPUESTAS AUTOMÁTICAS POR PALABRAS CLAVE
+-- =====================================================
+
+-- Bienvenida (primer mensaje)
+INSERT INTO autoresponses (trigger_type, trigger_value, response_type, response_text, menu_id, priority, is_active, created_at) VALUES
+(
+ 'welcome',
+ NULL,
+ 'menu',
+ NULL,
+ @menu_principal_id,
+ 100,
+ 1,
+ NOW()
+);
+
+-- Menú / Inicio
+INSERT INTO autoresponses (trigger_type, trigger_value, response_type, response_text, menu_id, priority, is_active, created_at) VALUES
+(
+ 'keyword',
+ 'menu,menú,inicio,start,opciones',
+ 'menu',
+ NULL,
+ @menu_principal_id,
+ 90,
+ 1,
+ NOW()
+);
+
+-- Precios
+INSERT INTO autoresponses (trigger_type, trigger_value, response_type, response_text, menu_id, priority, is_active, created_at) VALUES
+(
+ 'keyword',
+ 'precio,valor,costo,cuanto,cuánto,precios,costos',
+ 'text',
+ '💰 INFORMACIÓN DE PRECIOS
+
+Para darte información exacta, necesitamos saber:
+
+1️⃣ ¿Qué examen necesitas?
+2️⃣ ¿Es particular o por seguro?
+
+Puedes:
+• Enviar foto de tu orden médica
+• Escribir el nombre del examen
+• Escribir *COTIZAR* para más información
+
+También puedes ver nuestros servicios frecuentes:
+• EMBARAZO - Pruebas de embarazo
+• PATERNIDAD - Prueba de paternidad
+• DOMICILIO - Toma a domicilio',
+ NULL,
+ 80,
+ 1,
+ NOW()
+);
+
+-- Resultados
+INSERT INTO autoresponses (trigger_type, trigger_value, response_type, response_text, menu_id, priority, is_active, created_at) VALUES
+(
+ 'keyword',
+ 'resultado,resultados,descargar,reporte,examen',
+ 'text',
+ '📊 CONSULTA DE RESULTADOS
+
+Ingresa a:
+http://www.resultadoslab.com.co/resultadoslab/vistas/login.php?id=eGltY2FpY2Vkbw==
+
+Usuario: Tu documento
+Clave: Últimos 4 dígitos
+
+¿Problemas para descargar?
+Escribe: *AYUDA RESULTADOS*',
+ NULL,
+ 85,
+ 1,
+ NOW()
+);
+
+-- Horarios
+INSERT INTO autoresponses (trigger_type, trigger_value, response_type, response_text, menu_id, priority, is_active, created_at) VALUES
+(
+ 'keyword',
+ 'horario,hora,cuando,cuándo,abierto,atienden,abren,cierran',
+ 'text',
+ '🕐 HORARIOS DE ATENCIÓN
+
+🏥 *Toma de muestras:*
+Lunes a viernes: 6:15 AM - 3:00 PM
+
+📞 *Atención WhatsApp:*
+Lunes a viernes: 7:00 AM - 12:00 PM y 2:00 PM - 5:00 PM
+Sábados: 8:00 AM - 11:00 AM
+
+🏠 *Domicilios:*
+Lunes a viernes: 5:00 AM - 8:30 AM
+
+Para más información escribe: *UBICACION*',
+ NULL,
+ 85,
+ 1,
+ NOW()
+);
+
+-- Domicilio
+INSERT INTO autoresponses (trigger_type, trigger_value, response_type, response_text, menu_id, priority, is_active, created_at) VALUES
+(
+ 'keyword',
+ 'domicilio,casa,ir a mi casa,a casa,visita',
+ 'text',
+ '🏠 SERVICIO A DOMICILIO
+
+💵 Valor: $27.000
+⏰ Horario: 5:00 AM - 8:30 AM
+
+Para agendar escribe: 1 o *DOMICILIO*',
+ NULL,
+ 85,
+ 1,
+ NOW()
+);
+
+-- Embarazo
+INSERT INTO autoresponses (trigger_type, trigger_value, response_type, response_text, menu_id, priority, is_active, created_at) VALUES
+(
+ 'keyword',
+ 'embarazo,embarazada,gestación,gestacion,prueba embarazo',
+ 'text',
+ '🤰 PRUEBAS DE EMBARAZO
+
+💵 Cualitativa (Sí/No): $28.000
+💵 Cuantitativa (con valor): $61.000
+
+📝 Requisito: Mínimo 1 semana de retraso
+
+Para más información escribe: *INFO EMBARAZO*
+Para agendar escribe: *AGENDAR EMBARAZO*',
+ NULL,
+ 85,
+ 1,
+ NOW()
+);
+
+-- Paternidad
+INSERT INTO autoresponses (trigger_type, trigger_value, response_type, response_text, menu_id, priority, is_active, created_at) VALUES
+(
+ 'keyword',
+ 'paternidad,adn,paternity,prueba de paternidad',
+ 'text',
+ '👨👩👧 PRUEBA DE PATERNIDAD
+
+💵 Valor: $760.000
+📅 Resultado: 12-15 días
+🏥 Se realiza en laboratorio (NO a domicilio)
+
+Para más información escribe: *INFO PATERNIDAD*',
+ NULL,
+ 85,
+ 1,
+ NOW()
+);
+
+-- Ayuda con resultados
+INSERT INTO autoresponses (trigger_type, trigger_value, response_type, response_text, menu_id, priority, is_active, created_at) VALUES
+(
+ 'keyword',
+ 'ayuda resultados,no puedo descargar,ayuda descargar,problema resultados',
+ 'text',
+ '🆘 AYUDA CON RESULTADOS
+
+Para ayudarte a descargar tus resultados, necesitamos:
+
+📸 Foto del documento de identidad del paciente
+
+*IMPORTANTE:*
+Por política de seguridad del paciente, solo podemos enviar los resultados si nos envías la foto del documento.
+
+Si eres familiar del paciente, debes enviar el documento del titular de los exámenes.',
+ NULL,
+ 95,
+ 1,
+ NOW()
+);
+
+-- Ubicación
+INSERT INTO autoresponses (trigger_type, trigger_value, response_type, response_text, menu_id, priority, is_active, created_at) VALUES
+(
+ 'keyword',
+ 'ubicación,ubicacion,dirección,direccion,donde,dónde,como llego,cómo llego',
+ 'text',
+ '📍 UBICACIÓN
+
+Calle 21 #0-26, Barrio Blanco
+Cúcuta, Norte de Santander
+
+📌 Google Maps:
+https://maps.google.com/maps/search/Laboratorio%20Ximena%20Caicedo/@7.877200126647949,-72.49710083007812,17z?hl=es
+
+Para más información escribe: *HORARIOS*',
+ NULL,
+ 85,
+ 1,
+ NOW()
+);
+
+-- Preparación / Ayunas
+INSERT INTO autoresponses (trigger_type, trigger_value, response_type, response_text, menu_id, priority, is_active, created_at) VALUES
+(
+ 'keyword',
+ 'ayunas,preparación,preparacion,requisitos,como me preparo,cómo me preparo',
+ 'text',
+ '📋 PREPARACIÓN PARA EXÁMENES
+
+La preparación depende del examen que te van a realizar.
+
+¿Qué examen te van a tomar?
+
+Exámenes con preparación especial:
+• PSA - Antígeno prostático
+• Frotis vaginal
+• Urocultivo
+• Helicobacter pylori
+• Orina de 24 horas
+• Perfil lipídico (ayunas)
+
+Escribe el nombre del examen o envía foto de tu orden.',
+ NULL,
+ 85,
+ 1,
+ NOW()
+);
+
+-- Asesor / Ayuda / Persona
+INSERT INTO autoresponses (trigger_type, trigger_value, response_type, response_text, menu_id, priority, is_active, created_at) VALUES
+(
+ 'keyword',
+ 'asesor,persona,humano,ayuda,help,operador,hablar con alguien',
+ 'transfer',
+ '👤 SOLICITUD DE ASESOR
+
+Un momento por favor, estamos conectando tu consulta con uno de nuestros asesores.
+
+En breve te atenderemos.
+
+*Tu consulta es importante para nosotros* 💙',
+ NULL,
+ 70,
+ 1,
+ NOW()
+);
+
+-- Agradecimientos
+INSERT INTO autoresponses (trigger_type, trigger_value, response_type, response_text, menu_id, priority, is_active, created_at) VALUES
+(
+ 'keyword',
+ 'gracias,thanks,thank you,muchas gracias,ok gracias',
+ 'text',
+ '😊 ¡De nada! Con gusto.
+
+¿Necesitas algo más?
+Escribe *MENÚ* para ver todas las opciones.
+
+Laboratorio Ximena Caicedo
+Tu salud, nuestra prioridad 💙',
+ NULL,
+ 60,
+ 1,
+ NOW()
+);
+
+-- Despedidas
+INSERT INTO autoresponses (trigger_type, trigger_value, response_type, response_text, menu_id, priority, is_active, created_at) VALUES
+(
+ 'keyword',
+ 'adios,adiós,bye,chao,hasta luego,nos vemos',
+ 'text',
+ '👋 ¡Hasta pronto!
+
+Gracias por confiar en Laboratorio Ximena Caicedo.
+
+Si necesitas algo más, no dudes en escribirnos.
+
+¡Que tengas un excelente día! 😊',
+ NULL,
+ 60,
+ 1,
+ NOW()
+);
+
+-- Saludos generales
+INSERT INTO autoresponses (trigger_type, trigger_value, response_type, response_text, menu_id, priority, is_active, created_at) VALUES
+(
+ 'keyword',
+ 'hola,buenos dias,buenas tardes,buenas noches,buen dia,hey,hi,hello',
+ 'menu',
+ NULL,
+ @menu_principal_id,
+ 75,
+ 1,
+ NOW()
+);
+
+-- Respuesta por defecto (cuando no entiende)
+INSERT INTO autoresponses (trigger_type, trigger_value, response_type, response_text, menu_id, priority, is_active, created_at) VALUES
+(
+ 'default',
+ NULL,
+ 'menu',
+ '❓ No he comprendido tu mensaje
+
+Por favor selecciona una opción:',
+ @menu_principal_id,
+ 10,
+ 1,
+ NOW()
+);
+
+-- =====================================================
+-- 4. ACTUALIZAR CONFIGURACIÓN DEL SISTEMA
+-- =====================================================
+
+-- Actualizar mensaje de bienvenida en system_config
+UPDATE system_config
+SET config_value = (SELECT welcome_message FROM menus WHERE menu_key = 'lab_menu_principal' LIMIT 1)
+WHERE config_key = 'welcome_message';
+
+-- Agregar nombre de empresa si no existe
+INSERT INTO system_config (config_key, config_value, description, created_at)
+VALUES ('business_name', 'Laboratorio Ximena Caicedo', 'Nombre de la empresa/laboratorio', NOW())
+ON DUPLICATE KEY UPDATE config_value = 'Laboratorio Ximena Caicedo';
+
+-- Agregar horarios de atención
+INSERT INTO system_config (config_key, config_value, description, created_at)
+VALUES
+('business_hours_weekday', '7:00-12:00,14:00-17:00', 'Horario de atención lunes a viernes (formato 24h)', NOW()),
+('business_hours_saturday', '8:00-11:00', 'Horario de atención sábados (formato 24h)', NOW()),
+('business_hours_sunday', 'closed', 'Horario de atención domingos', NOW()),
+('out_of_hours_message', 'GRACIAS POR COMUNICARSE CON Laboratorio Ximena Caicedo ⏳\n\nEn este momento NO nos encontramos disponibles.\n\n*Horario de atención:*\n📅 Lunes a viernes: 7:00 AM - 12:00 PM y 2:00 PM - 5:00 PM\n📅 Sábados: 8:00 AM - 11:00 AM\n🚫 Domingos y festivos cerrado\n\nPor favor déjanos tu *Nombre* y tu *mensaje*, y con gusto te responderemos al retomar nuestro horario.\n\n💻 Este es un mensaje automático\nGracias por tu comprensión 😊', 'Mensaje automático fuera de horario', NOW())
+ON DUPLICATE KEY UPDATE config_value = VALUES(config_value);
+
+-- =====================================================
+-- CONFIRMACIÓN
+-- =====================================================
+
+SELECT
+ '✅ SETUP COMPLETADO' as status,
+ COUNT(DISTINCT m.id) as menus_creados,
+ COUNT(DISTINCT mo.id) as opciones_creadas,
+ COUNT(DISTINCT ar.id) as respuestas_automaticas
+FROM menus m
+LEFT JOIN menu_options mo ON m.id = mo.menu_id
+LEFT JOIN autoresponses ar ON ar.is_active = 1
+WHERE m.menu_key LIKE 'lab_%';
+
+-- Ver menús creados
+SELECT
+ id,
+ name,
+ menu_key,
+ status,
+ (SELECT COUNT(*) FROM menu_options WHERE menu_id = m.id) as num_opciones
+FROM menus m
+WHERE menu_key LIKE 'lab_%'
+ORDER BY created_at;
+
+-- Ver respuestas automáticas creadas
+SELECT
+ id,
+ trigger_type,
+ trigger_value,
+ response_type,
+ priority,
+ is_active
+FROM autoresponses
+WHERE is_active = 1
+ORDER BY priority DESC, id;
diff --git a/test.php b/test.php
deleted file mode 100644
index e2bda81..0000000
--- a/test.php
+++ /dev/null
@@ -1,204 +0,0 @@
-🤖 Test del Sistema WhatsApp Bot";
-
-// Verificar archivos principales
-$files = [
- 'config/config.php' => 'Configuración principal',
- 'classes/Database.php' => 'Clase Database',
- 'services/WhatsAppService.php' => 'Servicio WhatsApp',
- 'services/BotService.php' => 'Servicio Bot',
- 'api/webhook.php' => 'Webhook principal',
- 'database/schema.sql' => 'Esquema de BD',
- 'index.php' => 'Página principal'
-];
-
-echo "📂 Verificación de Archivos: ";
-echo "";
-foreach ($files as $file => $description) {
- $exists = file_exists($file);
- $status = $exists ? "✅ Existe" : "❌ Faltante";
- echo "{$description} ({$file}): {$status} ";
-}
-echo " ";
-
-// Probar configuración
-echo "⚙️ Verificación de Configuración: ";
-try {
- require_once 'config/config.php';
- echo "✅ Configuración cargada correctamente
";
- echo "";
- echo "DB Host: " . DB_HOST . " ";
- echo "DB Name: " . DB_NAME . " ";
- echo "WhatsApp Phone ID: " . WHATSAPP_PHONE_NUMBER_ID . " ";
- echo "App URL: " . APP_URL . " ";
- echo " ";
-} catch (Exception $e) {
- echo "❌ Error en configuración: " . $e->getMessage() . "
";
-}
-
-// Probar conexión a base de datos
-echo "💾 Verificación de Base de Datos: ";
-try {
- $db = Database::getInstance();
- echo "✅ Conexión a base de datos exitosa
";
-
- // Verificar tablas principales
- $tables = ['users', 'conversations', 'menus', 'menu_options', 'system_config'];
- echo "";
- foreach ($tables as $table) {
- try {
- $count = $db->fetch("SELECT COUNT(*) as count FROM {$table}")['count'];
- echo "Tabla {$table} : {$count} registros ";
- } catch (Exception $e) {
- echo "Tabla {$table} : ❌ Error: " . $e->getMessage() . " ";
- }
- }
- echo " ";
-
-} catch (Exception $e) {
- echo "❌ Error de conexión: " . $e->getMessage() . "
";
-}
-
-// Probar servicios
-echo "🔧 Verificación de Servicios: ";
-try {
- $whatsappService = new WhatsAppService();
- echo "✅ WhatsAppService inicializado
";
-
- $botService = new BotService();
- echo "✅ BotService inicializado
";
-
-} catch (Exception $e) {
- echo "❌ Error en servicios: " . $e->getMessage() . "
";
-}
-
-// Probar APIs
-echo "🌐 Verificación de APIs: ";
-$apis = [
- 'api/get_stats.php',
- 'api/get_users.php',
- 'api/get_menus.php',
- 'api/get_conversations.php',
- 'api/webhook.php'
-];
-
-echo "";
-foreach ($apis as $api) {
- $exists = file_exists($api);
- $readable = $exists ? is_readable($api) : false;
- $status = $readable ? "✅ Disponible" : "❌ No disponible";
- echo "{$api} : {$status} ";
-}
-echo " ";
-
-// Probar funcionalidad básica del bot
-echo "🤖 Test Básico del Bot: ";
-try {
- // Crear usuario de prueba
- $testPhone = '573000000000';
-
- // Verificar si el usuario existe
- $existingUser = $db->fetch(
- "SELECT * FROM users WHERE phone_number = :phone",
- ['phone' => $testPhone]
- );
-
- if (!$existingUser) {
- $userId = $db->insert('users', [
- 'phone_number' => $testPhone,
- 'name' => 'Usuario de Prueba',
- 'status' => 'active',
- 'created_at' => date('Y-m-d H:i:s')
- ]);
- echo "✅ Usuario de prueba creado (ID: {$userId})
";
- } else {
- echo "✅ Usuario de prueba ya existe
";
- }
-
- // Probar menú principal
- $mainMenu = $db->fetch(
- "SELECT * FROM menus WHERE is_root = 1 AND is_active = 1 LIMIT 1"
- );
-
- if ($mainMenu) {
- echo "✅ Menú principal configurado: " . $mainMenu['title'] . "
";
-
- // Probar opciones del menú
- $options = $db->fetchAll(
- "SELECT * FROM menu_options WHERE menu_id = :id ORDER BY option_number",
- ['id' => $mainMenu['id']]
- );
-
- echo "✅ Opciones del menú principal: " . count($options) . " opciones
";
-
- } else {
- echo "❌ No hay menú principal configurado
";
- }
-
-} catch (Exception $e) {
- echo "❌ Error en test del bot: " . $e->getMessage() . "
";
-}
-
-// URLs de prueba
-echo "🔗 URLs del Sistema: ";
-echo "";
-
-echo "";
-echo "
Nueva Suite de Tests Implementada";
-echo "
Se ha implementado una suite completa de tests que cubre:
";
-echo "
";
-echo "✅ 50+ Tests exhaustivos ";
-echo "🔌 17 APIs validadas ";
-echo "🔒 Seguridad completa ";
-echo "📝 Sistema de plantillas ";
-echo "🔗 Webhook y servicios ";
-echo "💾 Base de datos ";
-echo " ";
-echo "
Ejecutar Tests Completos
";
-echo "
";
-
-echo "📋 Resumen: ";
-echo "El sistema WhatsApp Bot está:
";
-
-// Calcular estado general
-$allGood = file_exists('config/config.php') &&
- file_exists('classes/Database.php') &&
- file_exists('api/webhook.php') &&
- class_exists('Database');
-
-if ($allGood) {
- echo "";
- echo "
✅ SISTEMA FUNCIONANDO ";
- echo "
Todos los componentes principales están funcionando correctamente.
";
- echo "
Próximos pasos:
";
- echo "
";
- echo "1. Configura el webhook en Facebook Developers ";
- echo "2. Ajusta la configuración en el panel ";
- echo "3. Personaliza los menús según tus necesidades ";
- echo "4. ¡Comienza a recibir mensajes! ";
- echo " ";
- echo "
";
-} else {
- echo "";
- echo "
❌ HAY PROBLEMAS ";
- echo "
Algunos componentes no están funcionando correctamente. Revisa los errores arriba.
";
- echo "
";
-}
-
-echo " ";
-echo "Test ejecutado el: " . date('Y-m-d H:i:s') . "
";
-?>
\ No newline at end of file
diff --git a/test_api.php b/test_api.php
deleted file mode 100644
index 1e1d372..0000000
--- a/test_api.php
+++ /dev/null
@@ -1,175 +0,0 @@
- ";
-echo "🧪 Test API ";
-echo "";
-
-echo "🧪 TEST DE LA API ";
-
-// Lista de endpoints para probar
-$endpoints = [
- 'get_settings.php' => 'Obtener configuración del sistema',
- 'get_stats.php' => 'Obtener estadísticas',
- 'get_users.php' => 'Obtener usuarios',
- 'get_menus.php' => 'Obtener menús',
- 'get_templates.php' => 'Obtener plantillas'
-];
-
-echo "";
-echo "
🚀 PROBANDO ENDPOINTS DE LA API... ";
-
-foreach ($endpoints as $endpoint => $description) {
- echo "
";
- echo "
🔗 $endpoint - $description
";
-
- $api_url = 'api/' . $endpoint;
-
- // Verificar que el archivo existe
- if (!file_exists($api_url)) {
- echo "
❌ Archivo no encontrado: $api_url
";
- continue;
- }
-
- echo "
✅ Archivo existe
";
-
- // Intentar ejecutar el endpoint usando output buffering
- ob_start();
-
- try {
- // Simular REQUEST_METHOD para endpoints que lo requieran
- $_SERVER['REQUEST_METHOD'] = 'GET';
- $_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? 'localhost';
-
- // Capturar cualquier error
- $error_before = error_get_last();
-
- // Incluir el archivo de la API
- include $api_url;
-
- // Obtener la salida
- $output = ob_get_contents();
-
- // Verificar errores
- $error_after = error_get_last();
-
- if ($error_after && $error_after !== $error_before) {
- echo "
❌ Error PHP: " . htmlspecialchars($error_after['message']) . "
";
- echo "
Línea: " . $error_after['line'] . " en " . basename($error_after['file']) . "
";
- } else {
- // Verificar si la salida parece ser JSON válido
- if (!empty($output)) {
- $json_data = json_decode($output, true);
- if (json_last_error() === JSON_ERROR_NONE) {
- echo "
✅ Respuesta JSON válida (" . strlen($output) . " caracteres)
";
-
- // Mostrar estructura básica
- if (is_array($json_data)) {
- $keys = array_keys($json_data);
- $preview_keys = array_slice($keys, 0, 3);
- echo "
📋 Claves: " . implode(', ', $preview_keys);
- if (count($keys) > 3) echo " (+" . (count($keys) - 3) . " más)";
- echo "
";
- }
- } else {
- echo "
⚠️ Salida no es JSON válido
";
- if (strlen($output) < 200) {
- echo "
Salida: " . htmlspecialchars(substr($output, 0, 100)) . "
";
- }
- }
- } else {
- echo "
⚠️ Sin salida
";
- }
- }
-
- } catch (ParseError $e) {
- echo "
❌ Error de sintaxis: " . htmlspecialchars($e->getMessage()) . "
";
- echo "
Línea: " . $e->getLine() . "
";
- } catch (Error $e) {
- echo "
❌ Error fatal: " . htmlspecialchars($e->getMessage()) . "
";
- echo "
Archivo: " . basename($e->getFile()) . " línea " . $e->getLine() . "
";
- } catch (Exception $e) {
- echo "
❌ Excepción: " . htmlspecialchars($e->getMessage()) . "
";
- } finally {
- ob_end_clean();
- }
-
- echo "
";
-}
-
-echo "
";
-
-// Test directo de carga de configuración desde API
-echo "";
-echo "
⚙️ TEST DIRECTO DE CONFIGURACIÓN... ";
-
-echo "
🔄 Simulando carga desde directorio API...
";
-
-// Cambiar al directorio API temporalmente
-$original_dir = getcwd();
-
-try {
- chdir('api');
- echo "
✅ Cambiado a directorio API
";
-
- // Intentar cargar la configuración
- if (file_exists('../config/config.php')) {
- echo "
✅ ../config/config.php existe desde API
";
-
- try {
- require_once '../config/config.php';
- echo "
✅ Configuración cargada exitosamente
";
-
- // Verificar algunas constantes
- $test_constants = ['DB_HOST', 'DB_NAME', 'DB_USER', 'SECRET_KEY'];
- foreach ($test_constants as $const) {
- if (defined($const)) {
- echo "
✅ $const definida
";
- } else {
- echo "
❌ $const no definida
";
- }
- }
-
- } catch (Exception $e) {
- echo "
❌ Error cargando config: " . htmlspecialchars($e->getMessage()) . "
";
- }
-
- } else {
- echo "
❌ ../config/config.php NO existe desde API
";
- }
-
-} finally {
- chdir($original_dir);
-}
-
-echo "
";
-
-// Resumen final
-echo "";
-echo "
📊 RESUMEN ";
-
-echo "
✅ Rutas de API corregidas
";
-echo "
✅ Todos los archivos usan '../config/config.php'
";
-
-echo "
PRÓXIMOS PASOS:
";
-echo "
";
-echo "
";
-echo "
";
-echo "
";
-
-echo "
";
-
-echo "";
-?>
\ No newline at end of file
diff --git a/test_api_simple.php b/test_api_simple.php
deleted file mode 100644
index c019f39..0000000
--- a/test_api_simple.php
+++ /dev/null
@@ -1,7 +0,0 @@
-
\ No newline at end of file
diff --git a/test_basico.php b/test_basico.php
deleted file mode 100644
index 74d7c1d..0000000
--- a/test_basico.php
+++ /dev/null
@@ -1,57 +0,0 @@
- ";
-echo "✅ Test Básico - WhatsApp Bot ";
-echo "";
-
-echo "";
-echo "
✅ ¡PHP FUNCIONANDO CORRECTAMENTE! ";
-echo "
Versión PHP: " . phpversion() . "
";
-echo "
Fecha: " . date('Y-m-d H:i:s') . "
";
-echo "
Servidor: " . ($_SERVER['SERVER_SOFTWARE'] ?? 'No disponible') . "
";
-echo "
";
-
-echo "";
-echo "
📋 Próximos pasos: ";
-echo "
";
-echo "Subir archivo diagnóstico: diagnostico_500.php ";
-echo "Verificar configuración: Revisar config/config.php ";
-echo "Probar login: login.php ";
-echo "Dashboard principal: index.php ";
-echo " ";
-echo "
";
-
-echo "";
-echo "
🔧 Si tienes Error 500: ";
-echo "
1. Ejecuta diagnóstico: Ve a diagnostico_500.php
";
-echo "
2. Desactiva .htaccess: Renómbralo a .htaccess.bak
";
-echo "
3. Verifica permisos: chmod 777 logs/ uploads/
";
-echo "
4. Revisa logs: En tu panel de hosting
";
-echo "
";
-
-// Test de escritura
-$test_file = 'logs/test_write.txt';
-if (is_dir('logs')) {
- if (is_writable('logs')) {
- if (file_put_contents($test_file, "Test: " . date('Y-m-d H:i:s'))) {
- echo "✅ Directorio logs/ es escribible
";
- unlink($test_file); // Limpiar archivo de prueba
- } else {
- echo "❌ No se puede escribir en logs/
";
- }
- } else {
- echo "❌ Directorio logs/ no tiene permisos de escritura
";
- }
-} else {
- echo "⚠️ Directorio logs/ no existe
";
-}
-
-echo "";
-?>
\ No newline at end of file
diff --git a/test_config.php b/test_config.php
deleted file mode 100644
index dfdf462..0000000
--- a/test_config.php
+++ /dev/null
@@ -1,214 +0,0 @@
- ";
-echo "🔧 Test Configuración ";
-echo "";
-
-echo "🔧 TEST CONFIGURACIÓN ";
-
-// 1. Verificar que el archivo existe
-if (!file_exists('config/config.php')) {
- echo "❌ CRÍTICO: config/config.php no encontrado
";
- echo "Asegúrate de que el archivo esté en: config/config.php
";
- exit;
-}
-
-echo "✅ config/config.php encontrado
";
-
-// 2. Verificar permisos del archivo
-if (!is_readable('config/config.php')) {
- echo "❌ CRÍTICO: config/config.php no es legible
";
- echo "Ejecuta: chmod 644 config/config.php
";
- exit;
-}
-
-echo "✅ config/config.php es legible
";
-
-// 3. Intentar cargar el archivo
-echo "";
-echo "
📋 CARGANDO CONFIGURACIÓN... ";
-
-try {
- // Capturar cualquier output
- ob_start();
-
- // Capturar errores
- $error_before = error_get_last();
-
- // Incluir el archivo
- require_once 'config/config.php';
-
- // Obtener output
- $output = ob_get_clean();
-
- // Verificar si hubo errores
- $error_after = error_get_last();
-
- if ($error_after && $error_after !== $error_before) {
- echo "
❌ ERROR al cargar config: " . htmlspecialchars($error_after['message']) . "
";
- echo "
Línea: " . $error_after['line'] . " en " . $error_after['file'] . "
";
- } elseif (!empty($output)) {
- echo "
⚠️ ADVERTENCIA: config.php produce output:
";
- echo "
" . htmlspecialchars($output) . "
";
- echo "
Los archivos de configuración NO deben producir output
";
- } else {
- echo "
✅ config/config.php cargado sin errores
";
- }
-
-} catch (ParseError $e) {
- echo "
❌ ERROR DE SINTAXIS en config.php:
";
- echo "
" . htmlspecialchars($e->getMessage()) . "
";
- echo "
Línea: " . $e->getLine() . "
";
- exit;
-} catch (Error $e) {
- echo "
❌ ERROR FATAL en config.php:
";
- echo "
" . htmlspecialchars($e->getMessage()) . "
";
- exit;
-} catch (Exception $e) {
- echo "
❌ EXCEPCIÓN en config.php:
";
- echo "
" . htmlspecialchars($e->getMessage()) . "
";
- exit;
-}
-
-echo "
";
-
-// 4. Verificar constantes requeridas
-echo "";
-echo "
🗄️ VERIFICANDO CONSTANTES DE BD... ";
-
-$required_constants = [
- 'DB_HOST' => 'Servidor de base de datos',
- 'DB_NAME' => 'Nombre de la base de datos',
- 'DB_USER' => 'Usuario de la base de datos',
- 'DB_PASS' => 'Contraseña de la base de datos',
- 'DB_PORT' => 'Puerto de la base de datos',
- 'DB_CHARSET' => 'Charset de la base de datos'
-];
-
-$all_defined = true;
-
-foreach ($required_constants as $const => $desc) {
- if (defined($const)) {
- $value = constant($const);
- $display_value = ($const === 'DB_PASS') ? str_repeat('*', strlen($value)) : $value;
- echo "
✅ $const = '$display_value' ($desc)
";
- } else {
- echo "
❌ $const no definida ($desc)
";
- $all_defined = false;
- }
-}
-
-if (!$all_defined) {
- echo "
❌ CRÍTICO: Faltan constantes de base de datos
";
- echo "
El sistema no puede funcionar sin estas constantes
";
-}
-
-echo "
";
-
-// 5. Test de conexión a BD (solo si todas las constantes están)
-if ($all_defined) {
- echo "";
- echo "
🔌 TEST CONEXIÓN BASE DE DATOS... ";
-
- try {
- $dsn = "mysql:host=" . DB_HOST . ";port=" . DB_PORT . ";dbname=" . DB_NAME . ";charset=" . DB_CHARSET;
-
- echo "
DSN: $dsn
";
-
- $pdo = new PDO($dsn, DB_USER, DB_PASS, [
- PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
- PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
- PDO::ATTR_TIMEOUT => 5
- ]);
-
- echo "
✅ CONEXIÓN EXITOSA a la base de datos
";
-
- // Verificar algunas tablas
- $tables_to_check = ['conversations', 'messages', 'settings', 'message_templates'];
- foreach ($tables_to_check as $table) {
- try {
- $stmt = $pdo->query("SHOW TABLES LIKE '$table'");
- if ($stmt->rowCount() > 0) {
- echo "
✅ Tabla '$table' existe
";
- } else {
- echo "
⚠️ Tabla '$table' NO existe
";
- }
- } catch (Exception $e) {
- echo "
❌ Error verificando tabla '$table': " . htmlspecialchars($e->getMessage()) . "
";
- }
- }
-
- } catch (PDOException $e) {
- echo "
❌ ERROR DE CONEXIÓN:
";
- echo "
" . htmlspecialchars($e->getMessage()) . "
";
- echo "
Verifica:
";
- echo "
• Host: " . DB_HOST . "
";
- echo "
• Base datos: " . DB_NAME . "
";
- echo "
• Usuario: " . DB_USER . "
";
- echo "
• Puerto: " . DB_PORT . "
";
- }
-
- echo "
";
-}
-
-// 6. Verificar otras constantes importantes
-echo "";
-echo "
🔑 OTRAS CONSTANTES DEL SISTEMA... ";
-
-$other_constants = [
- 'ADMIN_USERNAME' => 'Usuario administrador',
- 'ADMIN_PASSWORD' => 'Contraseña administrador',
- 'SECRET_KEY' => 'Clave secreta',
- 'WHATSAPP_TOKEN' => 'Token de WhatsApp',
- 'WHATSAPP_PHONE_NUMBER_ID' => 'ID del número de teléfono'
-];
-
-foreach ($other_constants as $const => $desc) {
- if (defined($const)) {
- $value = constant($const);
- if (in_array($const, ['ADMIN_PASSWORD', 'SECRET_KEY', 'WHATSAPP_TOKEN'])) {
- $display_value = str_repeat('*', min(strlen($value), 20));
- } else {
- $display_value = $value;
- }
- echo "
✅ $const = '$display_value' ($desc)
";
- } else {
- echo "
⚠️ $const no definida ($desc)
";
- }
-}
-
-echo "
";
-
-// 7. Resumen final
-echo "";
-echo "
📊 RESUMEN ";
-
-if ($all_defined) {
- echo "
✅ Configuración básica completa
";
- echo "
✅ Archivo config.php funcional
";
- echo "
✅ Sistema puede intentar funcionar
";
-
- echo "
SIGUIENTE PASO:
";
- echo "
";
- echo "
";
-} else {
- echo "
❌ Configuración incompleta
";
- echo "
❌ Sistema NO puede funcionar
";
- echo "
Corrige las constantes faltantes en config/config.php
";
-}
-
-echo "
";
-
-echo "";
-?>
\ No newline at end of file
diff --git a/test_config_system.php b/test_config_system.php
deleted file mode 100644
index 91f427e..0000000
--- a/test_config_system.php
+++ /dev/null
@@ -1,452 +0,0 @@
-
-
-
-
-
-
- Prueba Sistema de Configuraciones
-
-
-
-
-
🔧 Prueba Sistema de Configuraciones
-
-
-
- ⚠️ Instalación no completada: Algunas funciones pueden no estar disponibles.
-
-
-
-
-
-
-
-
-
-
Instalación completada: = isInstallationCompleted() ? '✅ Sí' : '❌ No' ?>
-
Sistema migrado: = isAuthSystemMigrated() ? '✅ Sí' : '❌ No' ?>
-
BD conectada:
- getMessage();
- }
- ?>
-
-
-
-
Tabla system_config:
- query("SHOW TABLES LIKE 'system_config'")->rowCount() > 0;
- if ($exists) {
- $count = $pdo->query("SELECT COUNT(*) as total FROM system_config")->fetch()['total'];
- echo "✅ Existe ($count registros)";
- } else {
- echo '❌ No existe';
- }
- } catch (Exception $e) {
- echo '❌ Error: ' . $e->getMessage();
- }
- ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Inicializar Defaults
-
Migrar a BD
-
Limpiar Cache
-
Ver Todas
-
-
-
-
-
-
-
-
-
-
-
-
-
- Cargando configuraciones...
-
-
-
-
-
-
-
-
-
-
-
-
- Validando configuración...
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/test_direct_api.php b/test_direct_api.php
deleted file mode 100644
index ae4ae60..0000000
--- a/test_direct_api.php
+++ /dev/null
@@ -1,100 +0,0 @@
-Test Diagnóstico Menús ";
-echo "";
-
-echo "🔧 Test Diagnóstico Get Menús API ";
-
-try {
- echo "📞 Llamada directa a get_menus.php ";
-
- // Hacer llamada a la API
- $url = 'http://localhost:8000/api/get_menus.php';
-
- echo "URL: $url
";
-
- $ch = curl_init();
- curl_setopt($ch, CURLOPT_URL, $url);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
- curl_setopt($ch, CURLOPT_HEADER, 1);
- curl_setopt($ch, CURLOPT_TIMEOUT, 30);
- curl_setopt($ch, CURLOPT_COOKIEFILE, ''); // Enable cookies
-
- // Agregar cookie de sesión si existe
- if (isset($_COOKIE['PHPSESSID'])) {
- curl_setopt($ch, CURLOPT_COOKIE, 'PHPSESSID=' . $_COOKIE['PHPSESSID']);
- }
-
- $response = curl_exec($ch);
- $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
- curl_close($ch);
-
- $headers = substr($response, 0, $headerSize);
- $body = substr($response, $headerSize);
-
- echo "🔍 Resultado de la llamada ";
- echo "HTTP Code: $httpCode
";
-
- echo "Headers: ";
- echo "" . htmlspecialchars($headers) . " ";
-
- echo "Body Response: ";
- echo "" . htmlspecialchars($body) . " ";
-
- // Intentar decodificar JSON
- if ($body) {
- $jsonData = json_decode($body, true);
- if ($jsonData !== null) {
- echo "📋 JSON Decodificado: ";
- echo "" . json_encode($jsonData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . " ";
-
- if (isset($jsonData['success'])) {
- if ($jsonData['success']) {
- echo "✅ API respondió exitosamente
";
- if (isset($jsonData['data'])) {
- echo "Menús encontrados: " . count($jsonData['data']) . "
";
- }
- } else {
- echo "❌ API reportó error: " . ($jsonData['error'] ?? 'Error desconocido') . "
";
- if (isset($jsonData['debug'])) {
- echo "Debug info: " . $jsonData['debug'] . "
";
- }
- }
- }
- } else {
- echo "❌ Respuesta no es JSON válido
";
- }
- }
-
- // Mostrar logs si existen
- echo "📋 Logs del Sistema ";
- $logFile = dirname(__DIR__) . '/logs/system.log';
- if (file_exists($logFile)) {
- $logs = file_get_contents($logFile);
- $recentLogs = array_slice(explode("\n", $logs), -20); // Últimas 20 líneas
- echo "Últimas 20 líneas del log: ";
- echo "" . htmlspecialchars(implode("\n", $recentLogs)) . " ";
- } else {
- echo "No se encontró archivo de log en: $logFile
";
- }
-
- // Mostrar error log de PHP si existe
- $errorLogFile = dirname(__DIR__) . '/logs/error.log';
- if (file_exists($errorLogFile)) {
- $errorLogs = file_get_contents($errorLogFile);
- $recentErrorLogs = array_slice(explode("\n", $errorLogs), -10); // Últimas 10 líneas
- echo "Últimas 10 líneas del error log: ";
- echo "" . htmlspecialchars(implode("\n", $recentErrorLogs)) . " ";
- }
-
-} catch (Exception $e) {
- echo "❌ Error en test: " . $e->getMessage() . "
";
-}
-
-echo "Test ejecutado: " . date('Y-m-d H:i:s') . "
";
-echo "";
-?>
\ No newline at end of file
diff --git a/test_functions.html b/test_functions.html
deleted file mode 100644
index 6e1b667..0000000
--- a/test_functions.html
+++ /dev/null
@@ -1,209 +0,0 @@
-
-
-
-
-
- Test Funciones Globales - WhatsApp Bot
-
-
-
-
-
-
-
-
-
-
-
-
-
Modales Disponibles:
-
- Test Modal Plantilla
-
-
-
- Test Modal Menú
-
-
-
- Test Bootstrap
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Este es el modal de plantillas funcionando correctamente.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/test_menus.php b/test_menus.php
deleted file mode 100644
index 43bcfe1..0000000
--- a/test_menus.php
+++ /dev/null
@@ -1,200 +0,0 @@
-";
-echo "Test Menús - WhatsApp Bot ";
-echo "";
-echo "";
-
-echo "🔧 Test de Funcionalidad de Menús ";
-
-try {
- $db = Database::getInstance();
- echo "✅ Conexión a base de datos: EXITOSA
";
-
- // Verificar tabla menus
- echo "📋 Verificando tabla 'menus' ";
- try {
- $result = $db->fetchAll("SHOW TABLES LIKE 'menus'");
- if (count($result) > 0) {
- echo "✅ Tabla 'menus' existe
";
-
- // Mostrar estructura
- $structure = $db->fetchAll("DESCRIBE menus");
- echo "Estructura de tabla 'menus': ";
- echo "";
- foreach ($structure as $column) {
- echo "{$column['Field']} - {$column['Type']} - {$column['Null']} - {$column['Default']}\n";
- }
- echo " ";
-
- } else {
- echo "❌ Tabla 'menus' NO existe
";
- echo "💡 Creando tabla 'menus'...
";
-
- $createMenusTable = "
- CREATE TABLE IF NOT EXISTS menus (
- id INT AUTO_INCREMENT PRIMARY KEY,
- name VARCHAR(255) NOT NULL,
- menu_key VARCHAR(100) NOT NULL,
- description TEXT,
- welcome_message TEXT,
- status ENUM('active', 'inactive') DEFAULT 'active',
- options_count INT DEFAULT 0,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- INDEX idx_menu_key (menu_key),
- INDEX idx_status (status)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
-
- $db->execute($createMenusTable);
- echo "✅ Tabla 'menus' creada
";
- }
- } catch (Exception $e) {
- echo "❌ Error verificando tabla 'menus': " . $e->getMessage() . "
";
- }
-
- // Verificar tabla menu_options
- echo "📋 Verificando tabla 'menu_options' ";
- try {
- $result = $db->fetchAll("SHOW TABLES LIKE 'menu_options'");
- if (count($result) > 0) {
- echo "✅ Tabla 'menu_options' existe
";
-
- // Mostrar estructura
- $structure = $db->fetchAll("DESCRIBE menu_options");
- echo "Estructura de tabla 'menu_options': ";
- echo "";
- foreach ($structure as $column) {
- echo "{$column['Field']} - {$column['Type']} - {$column['Null']} - {$column['Default']}\n";
- }
- echo " ";
-
- } else {
- echo "❌ Tabla 'menu_options' NO existe
";
- echo "💡 Creando tabla 'menu_options'...
";
-
- $createMenuOptionsTable = "
- CREATE TABLE IF NOT EXISTS menu_options (
- id INT AUTO_INCREMENT PRIMARY KEY,
- menu_id INT NOT NULL,
- option_key VARCHAR(10) NOT NULL,
- option_text VARCHAR(255) NOT NULL,
- option_action ENUM('message', 'submenu', 'template', 'function') DEFAULT 'message',
- option_value TEXT,
- order_index INT DEFAULT 0,
- is_active BOOLEAN DEFAULT true,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (menu_id) REFERENCES menus(id) ON DELETE CASCADE,
- INDEX idx_menu_id (menu_id),
- INDEX idx_option_key (option_key),
- INDEX idx_order (order_index)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
-
- $db->execute($createMenuOptionsTable);
- echo "✅ Tabla 'menu_options' creada
";
- }
- } catch (Exception $e) {
- echo "❌ Error verificando tabla 'menu_options': " . $e->getMessage() . "
";
- }
-
- // Verificar menús existentes
- echo "📱 Menús existentes ";
- try {
- $menus = $db->fetchAll("SELECT * FROM menus ORDER BY created_at DESC");
- if (count($menus) > 0) {
- echo "📋 Se encontraron " . count($menus) . " menús:
";
- echo "";
- foreach ($menus as $menu) {
- echo "ID: {$menu['id']} | Nombre: {$menu['name']} | Clave: {$menu['menu_key']} | Estado: {$menu['status']}\n";
- }
- echo " ";
- } else {
- echo "📋 No hay menús configurados aún
";
-
- // Crear menú de ejemplo
- echo "💡 Creando menú de ejemplo...
";
-
- $menuData = [
- 'name' => 'Menú Principal',
- 'menu_key' => 'main',
- 'description' => 'Menú principal del bot',
- 'welcome_message' => '¡Hola! Bienvenido a nuestro servicio. Por favor selecciona una opción:',
- 'status' => 'active',
- 'options_count' => 0
- ];
-
- $menuId = $db->insert('menus', $menuData);
-
- if ($menuId) {
- echo "✅ Menú de ejemplo creado (ID: {$menuId})
";
-
- // Crear opciones de ejemplo
- $options = [
- ['menu_id' => $menuId, 'option_key' => '1', 'option_text' => 'Información', 'option_action' => 'message', 'option_value' => 'Aquí tienes información sobre nuestros servicios', 'order_index' => 1],
- ['menu_id' => $menuId, 'option_key' => '2', 'option_text' => 'Soporte', 'option_action' => 'message', 'option_value' => 'Nuestro equipo de soporte te ayudará en breve', 'order_index' => 2],
- ['menu_id' => $menuId, 'option_key' => '0', 'option_text' => 'Salir', 'option_action' => 'message', 'option_value' => 'Gracias por contactarnos', 'order_index' => 3]
- ];
-
- foreach ($options as $option) {
- $db->insert('menu_options', $option);
- }
-
- // Actualizar contador de opciones
- $db->execute("UPDATE menus SET options_count = 3 WHERE id = :id", ['id' => $menuId]);
-
- echo "✅ Opciones de menú creadas
";
- }
- }
- } catch (Exception $e) {
- echo "❌ Error verificando menús: " . $e->getMessage() . "
";
- }
-
- // Test de API
- echo "🔌 Test de API ";
- try {
- $apiUrl = 'http://localhost:8000/api/get_menus.php';
- echo "📞 Probando API: {$apiUrl}
";
-
- $context = stream_context_create([
- 'http' => [
- 'method' => 'GET',
- 'header' => 'Content-Type: application/json',
- 'timeout' => 10
- ]
- ]);
-
- $response = @file_get_contents($apiUrl, false, $context);
-
- if ($response !== false) {
- $data = json_decode($response, true);
- if ($data !== null) {
- echo "✅ API responde correctamente
";
- echo "" . json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . " ";
- } else {
- echo "❌ API responde pero JSON inválido
";
- echo "" . htmlspecialchars($response) . " ";
- }
- } else {
- echo "❌ No se pudo conectar a la API
";
- }
- } catch (Exception $e) {
- echo "❌ Error probando API: " . $e->getMessage() . "
";
- }
-
-} catch (Exception $e) {
- echo "❌ Error de conexión: " . $e->getMessage() . "
";
-}
-
-echo " ";
-echo "Estado del servidor: El servidor PHP está corriendo en http://localhost:8000
";
-echo "Aplicación principal: WhatsApp Bot Manager
";
-echo "Fecha de prueba: " . date('Y-m-d H:i:s') . "
";
-
-echo "";
-?>
\ No newline at end of file
diff --git a/test_menus_api.html b/test_menus_api.html
deleted file mode 100644
index dd270b0..0000000
--- a/test_menus_api.html
+++ /dev/null
@@ -1,153 +0,0 @@
-
-
-
-
-
- Test Menús - WhatsApp Bot Manager
-
-
-
- 🔧 Test de Funcionalidad de Menús
- Este test verificará que la funcionalidad de menús esté trabajando correctamente.
-
-
-
- 🧪 Pruebas de API
- 📋 Probar get_menus.php
- 💾 Probar save_menu.php
- 🗑️ Probar delete_menu.php
- 🧽 Limpiar Resultados
-
-
-
-
-
-
\ No newline at end of file
diff --git a/test_menus_frontend.html b/test_menus_frontend.html
deleted file mode 100644
index 048fe9f..0000000
--- a/test_menus_frontend.html
+++ /dev/null
@@ -1,203 +0,0 @@
-
-
-
-
-
- Test JavaScript Menús
-
-
-
- 🧪 Test JavaScript - Lista de Menús
-
- 📋 Cargar y Mostrar Menús
- 🧽 Limpiar Tabla
-
-
-
- 📋 Tabla de Menús
-
-
-
- Nombre
- Clave
- Descripción
- Estado
- Opciones
- Fecha Creación
- Acciones
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/test_modal.html b/test_modal.html
deleted file mode 100644
index 16afa38..0000000
--- a/test_modal.html
+++ /dev/null
@@ -1,140 +0,0 @@
-
-
-
-
-
- Test Modal Plantillas - WhatsApp Bot
-
-
-
-
-
-
-
-
-
-
-
-
Haz clic en el botón para probar el modal de crear plantilla:
-
-
- Nueva Plantilla
-
-
-
- Test Bootstrap
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/test_navigation.html b/test_navigation.html
deleted file mode 100644
index 2f824ba..0000000
--- a/test_navigation.html
+++ /dev/null
@@ -1,85 +0,0 @@
-
-
-
-
-
- Test de Navegación - WhatsApp Bot
-
-
-
-
-
-
-
-
-
-
-
Dashboard
-
Contenido del dashboard aquí.
-
Esta es la pestaña de Dashboard
-
-
-
Usuarios
-
Lista de usuarios aquí.
-
Esta es la pestaña de Usuarios
-
-
-
Logs
-
Logs del sistema aquí.
-
Esta es la pestaña de Logs
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/test_pass.php b/test_pass.php
deleted file mode 100644
index 8dec55b..0000000
--- a/test_pass.php
+++ /dev/null
@@ -1,3 +0,0 @@
-
\ No newline at end of file
diff --git a/test_ses_fix.html b/test_ses_fix.html
deleted file mode 100644
index 2062634..0000000
--- a/test_ses_fix.html
+++ /dev/null
@@ -1,113 +0,0 @@
-
-
-
-
-
- Test SES Fix - WhatsApp Bot
-
-
-
-
-
-
-
-
-
-
-
Esta página prueba que las funciones globales funcionen sin errores de SES.
-
-
-
-
Funciones disponibles:
-
- Test showCreateTemplateModal
-
-
-
- Test showCreateMenuModal
-
-
-
- Limpiar Log
-
-
-
-
Status Log:
-
-
✅ Página cargada sin errores de SES
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/test_simple_config.php b/test_simple_config.php
deleted file mode 100644
index 60f9666..0000000
--- a/test_simple_config.php
+++ /dev/null
@@ -1,104 +0,0 @@
-query("SHOW TABLES LIKE 'system_config'")->rowCount() > 0;
-
- if (!$tableExists) {
- echo "⚠️ Tabla no existe, creándola...\n";
- $created = createSystemConfigTable($pdo);
- if ($created) {
- echo "✅ Tabla creada exitosamente\n\n";
- } else {
- echo "❌ Error creando tabla\n\n";
- exit;
- }
- } else {
- echo "✅ Tabla existe\n\n";
- }
-
- echo "3. Probando guardar configuración...\n";
- $testKey = 'test_config_' . time();
- $testValue = 'Valor de prueba ' . date('Y-m-d H:i:s');
-
- $saved = saveConfigToDB($testKey, $testValue, 'Configuración de prueba');
- if ($saved) {
- echo "✅ Configuración guardada: $testKey = $testValue\n\n";
- } else {
- echo "❌ Error guardando configuración\n\n";
- }
-
- echo "4. Probando obtener configuración...\n";
- $retrieved = getConfigFromDB($testKey, 'no_encontrado');
- if ($retrieved === $testValue) {
- echo "✅ Configuración recuperada correctamente: $retrieved\n\n";
- } else {
- echo "❌ Error recuperando configuración. Esperado: $testValue, Obtenido: $retrieved\n\n";
- }
-
- echo "5. Probando obtener todas las configuraciones...\n";
- $allConfigs = getAllConfigsFromDB();
- echo "✅ Total de configuraciones: " . count($allConfigs) . "\n\n";
-
- echo "6. Probando configuración de WhatsApp...\n";
- $whatsappTest = [
- 'token' => 'token_de_prueba_' . time(),
- 'phone_number_id' => '123456789',
- 'webhook_verify_token' => 'webhook_test_' . time()
- ];
-
- $whatsappSaved = saveWhatsAppConfigToDB($whatsappTest);
- if ($whatsappSaved) {
- echo "✅ Configuración de WhatsApp guardada\n";
-
- $whatsappConfig = getWhatsAppConfigFromDB();
- echo "📱 Token: " . $whatsappConfig['token'] . "\n";
- echo "📱 Phone ID: " . $whatsappConfig['phone_number_id'] . "\n";
- echo "📱 Webhook: " . $whatsappConfig['webhook_verify_token'] . "\n\n";
- } else {
- echo "❌ Error guardando configuración de WhatsApp\n\n";
- }
-
- echo "7. Probando validación de WhatsApp...\n";
- $validation = validateWhatsAppConfigFromDB();
- echo "✅ Configuración válida: " . ($validation['valid'] ? 'Sí' : 'No') . "\n";
- echo " Errores: " . count($validation['errors']) . "\n";
- echo " Advertencias: " . count($validation['warnings']) . "\n\n";
-
- echo "8. Limpiando configuración de prueba...\n";
- $deleted = deleteConfigFromDB($testKey);
- if ($deleted) {
- echo "✅ Configuración de prueba eliminada\n\n";
- } else {
- echo "❌ Error eliminando configuración de prueba\n\n";
- }
-
- echo "=== PRUEBAS COMPLETADAS EXITOSAMENTE ===\n";
- echo "El sistema de configuraciones funciona correctamente.\n";
- echo "Puedes usar las funciones:\n";
- echo "- saveConfigToDB(clave, valor, descripción)\n";
- echo "- getConfigFromDB(clave, por_defecto)\n";
- echo "- getAllConfigsFromDB()\n";
- echo "- saveWhatsAppConfigToDB(array_config)\n";
- echo "- getWhatsAppConfigFromDB()\n";
- echo "- validateWhatsAppConfigFromDB()\n";
-
-} catch (Exception $e) {
- echo "❌ ERROR: " . $e->getMessage() . "\n";
- echo "Trace: " . $e->getTraceAsString() . "\n";
-}
-?>
\ No newline at end of file
diff --git a/test_template_direct.php b/test_template_direct.php
deleted file mode 100644
index 5388a36..0000000
--- a/test_template_direct.php
+++ /dev/null
@@ -1,56 +0,0 @@
- 'Iniciando test de plantilla...'], JSON_PRETTY_PRINT) . "\n";
-
- $whatsapp = new WhatsAppService();
-
- // Usar datos de prueba que sabemos que existen
- $recipient = "573168950803"; // Número de prueba
- $templateName = "hello_world"; // Esta plantilla FUNCIONA
- $language = "en_US"; // Idioma que FUNCIONA
-
- echo json_encode([
- 'step' => 'Datos de envío',
- 'recipient' => $recipient,
- 'template' => $templateName,
- 'language' => $language
- ], JSON_PRETTY_PRINT) . "\n";
-
- // Enviar plantilla
- echo json_encode(['step' => 'Llamando sendTemplateMessage...'], JSON_PRETTY_PRINT) . "\n";
- flush();
-
- $result = $whatsapp->sendTemplateMessage($recipient, $templateName, $language);
-
- echo json_encode([
- 'step' => 'Resultado',
- 'success' => true,
- 'result' => $result
- ], JSON_PRETTY_PRINT) . "\n";
-
-} catch (Exception $e) {
- echo json_encode([
- 'step' => 'Error capturado',
- 'success' => false,
- 'error' => $e->getMessage(),
- 'file' => $e->getFile(),
- 'line' => $e->getLine()
- ], JSON_PRETTY_PRINT) . "\n";
-}
-
-echo json_encode(['status' => 'Test completado. Revisa los logs de error para ver detalles de la petición HTTP.'], JSON_PRETTY_PRINT) . "\n";
-?>
\ No newline at end of file
diff --git a/test_text_message.php b/test_text_message.php
deleted file mode 100644
index 72f4658..0000000
--- a/test_text_message.php
+++ /dev/null
@@ -1,92 +0,0 @@
- '+1234567890',
- 'type' => 'text',
- 'message' => 'Mensaje de prueba desde test directo'
-];
-
-echo "=== TEST MENSAJE DE TEXTO ===\n";
-echo "Datos de entrada: " . json_encode($testData) . "\n\n";
-
-try {
- // Test 1: Verificar conexión a la base de datos
- $db = Database::getInstance();
- echo "✅ Conexión a base de datos: OK\n";
-
- // Test 2: Verificar si existe la tabla users
- $stmt = $db->query("SHOW TABLES LIKE 'users'");
- $usersTable = $stmt->fetch();
- echo "✅ Tabla users existe: " . ($usersTable ? 'OK' : 'NO') . "\n";
-
- // Test 3: Verificar si existe la tabla conversations
- $stmt = $db->query("SHOW TABLES LIKE 'conversations'");
- $conversationsTable = $stmt->fetch();
- echo "✅ Tabla conversations existe: " . ($conversationsTable ? 'OK' : 'NO') . "\n";
-
- // Test 4: Simular búsqueda de usuario
- $recipient = $testData['recipient'];
- $stmt = $db->query("SELECT id FROM users WHERE phone_number = ? LIMIT 1", [$recipient]);
- $user = $stmt->fetch();
- echo "📱 Usuario existente con teléfono $recipient: " . ($user ? "ID=" . $user['id'] : 'NO EXISTE') . "\n";
-
- // Test 5: Simular creación de usuario si no existe
- if (!$user) {
- echo "🔄 Intentando crear nuevo usuario...\n";
- $stmt = $db->query(
- "INSERT INTO users (phone_number, name, created_at) VALUES (?, ?, NOW())",
- [$recipient, $recipient]
- );
- $userId = $db->lastInsertId();
- echo "✅ Usuario creado con ID: $userId\n";
- } else {
- $userId = $user['id'];
- echo "✅ Usuario existente con ID: $userId\n";
- }
-
- // Test 6: Verificar ventana de 24 horas
- if ($userId) {
- $stmt = $db->query(
- "SELECT MAX(created_at) as last_message FROM conversations
- WHERE user_id = ? AND direction = 'incoming'
- AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)",
- [$userId]
- );
- $lastMessage = $stmt->fetch();
- echo "⏰ Último mensaje entrante en 24h: " . ($lastMessage['last_message'] ?? 'NINGUNO') . "\n";
-
- if (!$lastMessage || !$lastMessage['last_message']) {
- echo "⚠️ Usuario fuera de ventana de 24h - Solo plantillas permitidas\n";
- } else {
- echo "✅ Usuario dentro de ventana de 24h - Mensajes de texto permitidos\n";
- }
- }
-
- // Test 7: Simular guardado de mensaje
- if ($userId) {
- echo "💾 Intentando guardar mensaje en conversations...\n";
- $messageContent = $testData['message'];
- $messageType = $testData['type'];
-
- $stmt = $db->query(
- "INSERT INTO conversations (user_id, content, direction, message_type, status, message_id, created_at) VALUES (?, ?, 'outgoing', ?, 'sent', ?, NOW())",
- [$userId, $messageContent, $messageType, 'test_message_' . time()]
- );
- echo "✅ Mensaje guardado correctamente en conversations\n";
- }
-
- echo "\n=== RESULTADO ===\n";
- echo "✅ Todas las operaciones de base de datos funcionan correctamente\n";
- echo "✅ El problema debería estar resuelto\n";
-
-} catch (Exception $e) {
- echo "❌ ERROR: " . $e->getMessage() . "\n";
- echo "Stack trace: " . $e->getTraceAsString() . "\n";
-}
-?>
\ No newline at end of file
diff --git a/test_webhook.php b/test_webhook.php
deleted file mode 100644
index f45bb5f..0000000
--- a/test_webhook.php
+++ /dev/null
@@ -1,366 +0,0 @@
-
-
-
-
-
-
- Diagnóstico de Webhook - WhatsApp Bot
-
-
-
-
-
-
-
-
- Diagnóstico de Webhook WhatsApp
-
-
Verificación completa del estado del webhook y configuración
-
-
- ";
- echo "
1. Configuración del Sistema";
-
- $config = [
- 'WHATSAPP_TOKEN' => defined('WHATSAPP_TOKEN') ? WHATSAPP_TOKEN : null,
- 'WHATSAPP_PHONE_NUMBER_ID' => defined('WHATSAPP_PHONE_NUMBER_ID') ? WHATSAPP_PHONE_NUMBER_ID : null,
- 'WEBHOOK_VERIFY_TOKEN' => defined('WEBHOOK_VERIFY_TOKEN') ? WEBHOOK_VERIFY_TOKEN : null,
- 'WHATSAPP_API_URL' => defined('WHATSAPP_API_URL') ? WHATSAPP_API_URL : null,
- ];
-
- echo "
";
- foreach ($config as $key => $value) {
- $status = !empty($value) ? 'ok' : 'error';
- $icon = !empty($value) ? '✅' : '❌';
- $displayValue = !empty($value) ? (strlen($value) > 30 ? substr($value, 0, 30) . '...' : $value) : 'NO CONFIGURADO';
-
- if ($status === 'error') $allOk = false;
-
- echo "";
- echo "$icon $key ";
- echo "$displayValue ";
- echo " ";
- }
- echo "
";
- echo "
";
-
- // Test 2: URL del Webhook
- echo "";
- echo "
2. URL del Webhook";
-
- $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
- $host = $_SERVER['HTTP_HOST'];
- $webhookUrl = $protocol . '://' . $host . '/api/webhook.php';
-
- echo "
";
- echo "URL para configurar en Meta/Facebook: ";
- echo "$webhookUrl";
- echo "
- Copiar
- ";
- echo "
";
-
- // Verificar si el archivo existe
- $webhookFile = __DIR__ . '/api/webhook.php';
- if (file_exists($webhookFile)) {
- echo "
✅ Archivo webhook.php existe
";
- } else {
- echo "
❌ Archivo webhook.php NO encontrado
";
- $allOk = false;
- }
-
- echo "
";
-
- // Test 3: Verificación GET
- echo "";
- echo "
3. Test de Verificación (GET)";
-
- $verifyUrl = $webhookUrl . '?hub.mode=subscribe&hub.verify_token=' . urlencode(WEBHOOK_VERIFY_TOKEN) . '&hub.challenge=test_challenge_12345';
-
- echo "
URL de prueba:
";
- echo "
" . htmlspecialchars($verifyUrl) . "
";
-
- echo "
";
- echo "💡 Nota: Este test intenta verificar el webhook desde el servidor mismo. ";
- echo "Si estás en localhost, es normal que falle. Lo importante es que el archivo exista y esté configurado correctamente.";
- echo "
";
-
- // Intentar hacer la verificación solo si no es localhost
- $isLocalhost = (strpos($host, 'localhost') !== false || strpos($host, '127.0.0.1') !== false);
-
- if (!$isLocalhost) {
- $ch = curl_init();
- curl_setopt($ch, CURLOPT_URL, $verifyUrl);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($ch, CURLOPT_TIMEOUT, 10);
- curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
- curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
-
- $response = curl_exec($ch);
- $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- $error = curl_error($ch);
- curl_close($ch);
-
- if ($httpCode == 200 && $response === 'test_challenge_12345') {
- echo "
✅ Verificación GET exitosa - El webhook responde correctamente
";
- echo "
Respuesta: $response
";
- } else {
- echo "
❌ Error en verificación GET
";
- echo "
Código HTTP: $httpCode
";
- echo "
Respuesta: " . htmlspecialchars($response) . "
";
- if ($error) echo "
Error cURL: $error
";
- $allOk = false;
- }
- } else {
- // Test manual para localhost
- echo "
";
- echo "⚠️ Entorno Local Detectado ";
- echo "No se puede hacer la verificación automática en localhost. ";
- echo "En su lugar, puedes probar manualmente:";
- echo "
";
-
- echo "
";
- echo "Haz clic en el botón de abajo para probar el webhook ";
- echo "Deberías ver el texto: test_challenge_12345 ";
- echo " ";
-
- echo "
";
- echo " Probar Webhook Manualmente";
- echo " ";
-
- // Verificar el archivo directamente
- $webhookFilePath = __DIR__ . '/api/webhook.php';
- if (file_exists($webhookFilePath)) {
- echo "
✅ El archivo webhook.php existe y es accesible
";
-
- // Verificar permisos de lectura
- if (is_readable($webhookFilePath)) {
- echo "
✅ El archivo tiene permisos de lectura
";
-
- // Verificar que contenga el código necesario
- $webhookContent = file_get_contents($webhookFilePath);
- if (strpos($webhookContent, 'hub_verify_token') !== false &&
- strpos($webhookContent, 'hub_challenge') !== false) {
- echo "
✅ El archivo contiene el código de verificación correcto
";
- } else {
- echo "
⚠️ El archivo puede no contener el código de verificación
";
- }
- } else {
- echo "
❌ El archivo no tiene permisos de lectura
";
- $allOk = false;
- }
- }
- }
-
- echo "
";
-
- // Test 4: Estructura de tablas
- echo "";
- echo "
4. Estructura de Base de Datos";
-
- $requiredTables = ['users', 'conversations', 'menus', 'menu_options', 'autoresponses', 'webhook_logs'];
-
- echo "
";
- foreach ($requiredTables as $table) {
- $exists = $db->fetchAll("SHOW TABLES LIKE '$table'");
- $status = !empty($exists) ? 'ok' : 'error';
- $icon = !empty($exists) ? '✅' : '❌';
-
- if ($status === 'error') $allOk = false;
-
- echo "";
- echo "$icon Tabla: $table ";
-
- if (!empty($exists)) {
- // Contar registros
- $count = $db->fetch("SELECT COUNT(*) as total FROM $table");
- echo "{$count['total']} registros ";
- } else {
- echo "NO EXISTE ";
- }
-
- echo " ";
- }
- echo "
";
- echo "
";
-
- // Test 5: Últimos mensajes recibidos
- echo "";
- echo "
5. Mensajes Recibidos Recientemente";
-
- $recentMessages = $db->fetchAll(
- "SELECT c.*, u.phone_number, u.name
- FROM conversations c
- LEFT JOIN users u ON c.user_id = u.id
- WHERE c.direction = 'incoming'
- ORDER BY c.created_at DESC
- LIMIT 5"
- );
-
- if (!empty($recentMessages)) {
- echo "
✅ Se encontraron mensajes entrantes
";
- echo "
";
- echo "Fecha Usuario Mensaje Estado ";
- echo "";
- foreach ($recentMessages as $msg) {
- $date = date('d/m/Y H:i', strtotime($msg['created_at']));
- $phone = $msg['phone_number'] ?? 'Desconocido';
- $content = htmlspecialchars(substr($msg['content'], 0, 50));
- $status = $msg['status'];
-
- echo "";
- echo "$date ";
- echo "$phone ";
- echo "$content ";
- echo "$status ";
- echo " ";
- }
- echo "
";
- } else {
- echo "
⚠️ No se encontraron mensajes entrantes. El webhook puede no estar recibiendo datos.
";
- echo "
";
- echo "
Posibles causas: ";
- echo "El webhook no está configurado en Meta/Facebook ";
- echo "La URL del webhook es incorrecta ";
- echo "El servidor no es accesible desde Internet ";
- echo "Aún no se han enviado mensajes de prueba ";
- echo " ";
- }
-
- echo "
";
-
- // Test 6: Webhook Logs
- echo "";
- echo "
6. Logs del Webhook";
-
- $webhookLogs = $db->fetchAll(
- "SELECT * FROM webhook_logs
- ORDER BY created_at DESC
- LIMIT 5"
- );
-
- if (!empty($webhookLogs)) {
- echo "
✅ Se encontraron logs del webhook
";
- echo "
";
- echo "Fecha Status IP Detalles ";
- echo "";
- foreach ($webhookLogs as $log) {
- $date = date('d/m/Y H:i:s', strtotime($log['created_at']));
- $status = $log['status_code'];
- $ip = $log['ip_address'];
- $statusClass = $status == 200 ? 'success' : 'danger';
-
- echo "";
- echo "$date ";
- echo "$status ";
- echo "$ip ";
- echo "Ver ";
- echo " ";
- }
- echo "
";
- } else {
- echo "
ℹ️ No hay logs del webhook registrados
";
- }
-
- echo "
";
-
- // Test 7: Configuración de Menús y Respuestas
- echo "";
- echo "
7. Configuración del Bot";
-
- $menuCount = $db->fetch("SELECT COUNT(*) as total FROM menus WHERE is_active = 1");
- $optionCount = $db->fetch("SELECT COUNT(*) as total FROM menu_options WHERE is_active = 1");
- $autoResponseCount = $db->fetch("SELECT COUNT(*) as total FROM autoresponses WHERE is_active = 1");
-
- echo "
";
- echo "
";
- echo "
";
- echo "
{$menuCount['total']} ";
- echo "
Menús Activos
";
- echo "
";
- echo "
";
- echo "
";
- echo "
";
- echo "
{$optionCount['total']} ";
- echo "
Opciones de Menú
";
- echo "
";
- echo "
";
- echo "
";
- echo "
";
- echo "
{$autoResponseCount['total']} ";
- echo "
Respuestas Automáticas
";
- echo "
";
- echo "
";
- echo "
";
-
- echo "
";
-
- // Resumen final
- echo "";
- if ($allOk) {
- echo "
";
- echo "
¡Sistema Configurado Correctamente!";
- echo "
El webhook está listo para recibir mensajes de WhatsApp.
";
- echo "
";
- } else {
- echo "
";
- echo "
Se encontraron problemas";
- echo "
Revisa los errores anteriores y corrígelos antes de continuar.
";
- echo "
";
- }
-
- echo "
";
- echo "
Próximos pasos: ";
- echo "
";
- echo "Copia la URL del webhook y configúrala en Meta Developer Console ";
- echo "Usa el token de verificación: " . WEBHOOK_VERIFY_TOKEN . " ";
- echo "Envía un mensaje de prueba desde WhatsApp ";
- echo "Verifica que aparezca en la sección de mensajes recibidos ";
- echo " ";
- echo "
";
-
- echo "
";
- ?>
-
-
-
- Volver al Dashboard
-
-
- Recargar Test
-
-
-
-
-
-
-
diff --git a/test_whatsapp_completo.php b/test_whatsapp_completo.php
deleted file mode 100644
index 238e997..0000000
--- a/test_whatsapp_completo.php
+++ /dev/null
@@ -1,162 +0,0 @@
-🔍 Diagnóstico Avanzado de WhatsApp API";
-
-// 1. Verificar configuración actual
-echo "1. Configuración Actual ";
-echo "TOKEN: " . substr(WHATSAPP_TOKEN, 0, 20) . "..." . substr(WHATSAPP_TOKEN, -10) . " ";
-echo "PHONE_NUMBER_ID: " . WHATSAPP_PHONE_NUMBER_ID . " ";
-echo "API_URL: " . WHATSAPP_API_URL . " ";
-
-// 2. Test de conectividad con WhatsApp API
-echo "2. Test de Conectividad ";
-
-try {
- // Test 1: Verificar información del número de teléfono
- $ch = curl_init();
- $url = WHATSAPP_API_URL . WHATSAPP_PHONE_NUMBER_ID;
-
- curl_setopt_array($ch, [
- CURLOPT_URL => $url,
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_HTTPHEADER => [
- 'Authorization: Bearer ' . WHATSAPP_TOKEN,
- 'Content-Type: application/json'
- ],
- CURLOPT_TIMEOUT => 30
- ]);
-
- $response = curl_exec($ch);
- $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- $error = curl_error($ch);
- curl_close($ch);
-
- if ($error) {
- echo "❌ Error de conexión: " . $error . " ";
- } else {
- echo "📞 Código HTTP: $httpCode ";
- if ($httpCode == 200) {
- $data = json_decode($response, true);
- echo "✅ Conexión exitosa con WhatsApp API ";
- echo "" . json_encode($data, JSON_PRETTY_PRINT) . " ";
- } else {
- echo "❌ Error HTTP $httpCode ";
- echo "$response ";
- }
- }
-
-} catch (Exception $e) {
- echo "❌ Excepción: " . $e->getMessage() . " ";
-}
-
-// 3. Test de mensaje a número de prueba (tu propio número)
-echo "3. Test de Mensaje Real ";
-
-// Crear un botón para probar envío
-?>
-
-
-🚀 Resultado del Test:";
-
- try {
- $whatsapp = new WhatsAppService();
- $phone = $_POST['phone'];
- $message = $_POST['message'];
-
- echo "📞 Enviando a: $phone ";
- echo "💬 Mensaje: $message ";
-
- $response = $whatsapp->sendTextMessage($phone, $message);
-
- echo "✅ Respuesta de WhatsApp API: ";
- echo "";
- echo json_encode($response, JSON_PRETTY_PRINT);
- echo " ";
-
- if (isset($response['messages'][0]['id'])) {
- $messageId = $response['messages'][0]['id'];
- echo "📨 Mensaje enviado con ID: $messageId
";
- echo "⏱️ Espera 1-2 minutos y verifica tu WhatsApp
";
-
- // Mostrar información adicional
- echo "🔍 Información del Envío: ";
- if (isset($response['contacts'][0])) {
- $contact = $response['contacts'][0];
- echo "📱 Número Input: " . $contact['input'] . " ";
- echo "📱 WhatsApp ID: " . $contact['wa_id'] . " ";
- }
- }
-
- } catch (Exception $e) {
- echo "❌ Error: " . $e->getMessage() . " ";
- }
-}
-
-// 4. Verificar status del mensaje reciente
-echo "4. Verificar Logs del Sistema ";
-$logFile = 'logs/system.log';
-if (file_exists($logFile)) {
- $logs = file_get_contents($logFile);
- $lines = array_filter(array_slice(explode("\n", $logs), -20)); // Últimas 20 líneas
-
- echo "";
- foreach ($lines as $line) {
- if (strpos($line, 'ERROR') !== false) {
- echo "$line \n";
- } elseif (strpos($line, 'WARNING') !== false) {
- echo "$line \n";
- } else {
- echo "$line\n";
- }
- }
- echo " ";
-} else {
- echo "⚠️ No hay archivo de logs disponible ";
-}
-
-// 5. Posibles soluciones
-echo "5. ✅ Posibles Soluciones ";
-echo "";
-echo "
🔧 Si el mensaje no llega, verifica: ";
-echo "
";
-echo "Modo de desarrollo: ¿Tu app está en modo sandbox? Los mensajes solo llegan a números pre-aprobados ";
-echo "Número verificado: ¿El número desde el que envías está verificado en Facebook Business? ";
-echo "Permisos del token: ¿Tu token tiene permisos 'whatsapp_business_messaging'? ";
-echo "Número de destino: ¿El número +573168950803 tiene WhatsApp activo? ";
-echo "Rate limiting: ¿Has enviado muchos mensajes recientemente? ";
-echo "Webhook URL: ¿Tu webhook está configurado correctamente para recibir respuestas? ";
-echo " ";
-echo "
";
-
-echo "";
-echo "
📋 Pasos para resolver: ";
-echo "
";
-echo "Ve a Facebook Developers ";
-echo "Selecciona tu app → WhatsApp → Configuración ";
-echo "Verifica que el número esté verificado y activo ";
-echo "Revisa la sección 'Phone Numbers' y asegúrate que esté en modo Producción ";
-echo "En 'Webhooks', verifica que tu URL esté configurada y validada ";
-echo " ";
-echo "
";
-?>
\ No newline at end of file
diff --git a/test_whatsapp_config.php b/test_whatsapp_config.php
deleted file mode 100644
index 8f4c79f..0000000
--- a/test_whatsapp_config.php
+++ /dev/null
@@ -1,52 +0,0 @@
-Test de Configuración WhatsApp";
-
-// Obtener configuración desde BD
-$config = getWhatsAppConfigFromDB();
-
-echo "Configuración desde Base de Datos: ";
-echo "";
-echo "Token: " . (strlen($config['token']) > 0 ? substr($config['token'], 0, 20) . "... (length: " . strlen($config['token']) . ")" : "EMPTY") . "\n";
-echo "Phone Number ID: " . ($config['phone_number_id'] ?: "EMPTY") . "\n";
-echo "API URL: " . ($config['api_url'] ?: "EMPTY") . "\n";
-echo " ";
-
-// Construir URL como lo haría WhatsAppService
-$apiUrl = rtrim($config['api_url'], '/') . '/' . $config['phone_number_id'] . '/messages';
-echo "URL que se construirá: ";
-echo "" . $apiUrl . " ";
-
-// Comparar con tu curl que funciona
-$expectedUrl = "https://graph.facebook.com/v22.0/938064202726485/messages";
-echo "URL que debería ser: ";
-echo "" . $expectedUrl . " ";
-
-echo "¿Coinciden las URLs? ";
-echo "" . ($apiUrl === $expectedUrl ? "✅ SÍ" : "❌ NO") . " ";
-
-// Verificar token
-$expectedTokenStart = "EAAcGjPRqunIBQVT6b86O0wWWGLGJ1PANmolIRXRBmEcpqa3P85VlUz0Rrv2c5deXky3gOKZCBw7ZCpdicly4c0G8qFMOH1DfOcSzmV49FlPId3AGPlHEXpqlsPFIE8DF18trW2vglQrFiCHXZCEBSUBQfp1jEASD1NrKKKVHiGVZARrwoxP0J931u0KIr2ZAZCug4fFpnyRZBls9LEgJuomXd4ZCnJtBZA4GvP2zaplmZA52mJiG5H4YiKKWby5GnQq9BgAVaq6sLZBnYI26aR0FgZDZD";
-
-echo "¿Coincide el token? ";
-$tokenMatch = substr($config['token'], 0, 50) === substr($expectedTokenStart, 0, 50);
-echo "" . ($tokenMatch ? "✅ SÍ (primeros 50 caracteres)" : "❌ NO") . " ";
-
-if (!$tokenMatch) {
- echo "Debug Token: ";
- echo "";
- echo "Configurado: " . substr($config['token'], 0, 50) . "...\n";
- echo "Esperado: " . substr($expectedTokenStart, 0, 50) . "...\n";
- echo " ";
-}
-
-// Test directo de configuración
-echo "Configuraciones Individuales: ";
-echo "";
-foreach(['whatsapp_token', 'whatsapp_phone_number_id', 'whatsapp_api_url'] as $key) {
- $value = getConfigFromDB($key, 'NO_ENCONTRADO');
- echo "$key: " . ($value === 'NO_ENCONTRADO' ? "❌ NO_ENCONTRADO" : "✅ " . substr($value, 0, 30) . "...") . "\n";
-}
-echo " ";
-?>
\ No newline at end of file
diff --git a/whatsapp_tester.zip b/whatsapp_tester.zip
new file mode 100644
index 0000000000000000000000000000000000000000..41d5756ce0fd7f8278c0fc3db13376c9fd42ca8c
GIT binary patch
literal 583367
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
z
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