delete test

This commit is contained in:
lizandrogd
2026-01-13 01:48:33 -05:00
parent b0d44b0095
commit 0544e81463
34 changed files with 3141 additions and 3337 deletions
+387
View File
@@ -0,0 +1,387 @@
<?php
/**
* Bot Service Mejorado - Laboratorio Ximena Caicedo
* Integra todos los flujos y servicios del chatbot
* Fecha: 13 de enero de 2026
*/
require_once __DIR__ . '/WhatsAppService.php';
require_once __DIR__ . '/BusinessHoursService.php';
require_once __DIR__ . '/ConversationStateService.php';
require_once __DIR__ . '/NLPService.php';
require_once __DIR__ . '/MenuService.php';
class BotServiceLab
{
private $db;
private $whatsappService;
private $businessHoursService;
private $stateService;
private $nlpService;
private $menuService;
public function __construct()
{
$this->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");
}
}
+221
View File
@@ -0,0 +1,221 @@
<?php
/**
* Servicio de Horarios de Atención
* Detecta si el bot está dentro del horario de atención
* y proporciona mensajes personalizados
*/
class BusinessHoursService
{
private $db;
public function __construct()
{
$this->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;
}
}
+293
View File
@@ -0,0 +1,293 @@
<?php
/**
* Servicio de Gestión de Estados de Conversación
* Controla el flujo y contexto de las conversaciones
*/
class ConversationStateService
{
private $db;
// Estados posibles
const STATE_INITIAL = 'initial';
const STATE_MAIN_MENU = 'main_menu';
const STATE_SUBMENU_EXAMS = 'submenu_exams';
const STATE_REQUESTING_HOME_SERVICE = 'requesting_home_service';
const STATE_AWAITING_ORDER_PHOTO = 'awaiting_order_photo';
const STATE_AWAITING_PATIENT_DATA = 'awaiting_patient_data';
const STATE_AWAITING_ID_PHOTO = 'awaiting_id_photo';
const STATE_VALIDATING_DATA = 'validating_data';
const STATE_QUOTATION = 'quotation';
const STATE_AWAITING_CONFIRMATION = 'awaiting_confirmation';
const STATE_SCHEDULED = 'scheduled';
const STATE_REQUESTING_RESULTS_HELP = 'requesting_results_help';
const STATE_WITH_ADVISOR = 'with_advisor';
const STATE_COMPLETED = 'completed';
public function __construct()
{
$this->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);
}
}
+222
View File
@@ -0,0 +1,222 @@
<?php
/**
* Servicio de Gestión de Menús
* Maneja la obtención y navegación de menús
*/
class MenuService
{
private $db;
public function __construct()
{
$this->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']);
}
}
+377
View File
@@ -0,0 +1,377 @@
<?php
/**
* Servicio de Procesamiento de Lenguaje Natural (NLP)
* Detecta intenciones y palabras clave en los mensajes
*/
class NLPService
{
private $db;
public function __construct()
{
$this->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';
}
}
+315
View File
@@ -0,0 +1,315 @@
<?php
/**
* Validadores de Datos del Paciente
* Valida información para agendamientos y procesos
*/
class PatientDataValidator
{
/**
* Valida todos los datos del paciente para agendamiento
* @param array $data
* @return array ['valid' => 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;
}
}