delete test
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user