Files
whatsapp/services/BotService.php
T
2026-01-21 15:12:15 -05:00

506 lines
20 KiB
PHP

<?php
/**
* Servicio Bot - Manejo de conversaciones y menús
* Fecha: 13 de noviembre de 2025
*/
class BotService {
private $db;
private $whatsappService;
public function __construct() {
$this->db = Database::getInstance();
$this->whatsappService = new WhatsAppService();
}
/**
* Procesar mensaje entrante
*/
public function processMessage($user, $messageText, $messageType = 'text') {
// Solo procesar mensajes de texto
if ($messageType !== 'text') {
return;
}
$messageText = trim($messageText);
$phoneNumber = $user['phone_number'];
// DEBUG: log basic context
try {
$userId = $user['id'] ?? 'unknown';
error_log("[BotService] processMessage start - user_id={$userId} phone={$phoneNumber} type={$messageType} message='" . substr($messageText,0,200) . "' current_menu_id=" . ($user['current_menu_id'] ?? 'null') . " on_hold=" . ($user['on_hold'] ?? '0') . " advisor_requested=" . ($user['advisor_requested'] ?? '0') . " bot_paused_until=" . ($user['bot_paused_until'] ?? 'null'));
} catch (Throwable $t) {
// ignore logging errors
}
// Verificar si es un nuevo usuario (enviar mensaje de bienvenida)
if ($this->isNewUser($user['id'])) {
$this->sendWelcomeMessage($phoneNumber);
return;
}
// Procesar comandos especiales
if ($this->processSpecialCommands($phoneNumber, $messageText)) {
return;
}
// Si el usuario está en espera por un asesor, y el periodo no expiró, no procesar.
if (!empty($user['on_hold']) && $user['on_hold']) {
$now = time();
$until = null;
if (!empty($user['bot_paused_until'])) {
$until = strtotime($user['bot_paused_until']);
}
// Si hay un tiempo de expiración y aún no pasó, notificar y retornar
if ($until && $until > $now) {
try {
$this->whatsappService->sendTextMessage($phoneNumber, "⚠️ Has sido puesto en espera por un asesor. Estarás en espera hasta " . date('H:i', $until) . ". Si no hay respuesta, podrás usar *menu*." );
} catch (Exception $e) {
// ignore
}
error_log("[BotService] processMessage - returning early because on_hold active for user {$user['id']}");
return;
} else {
// El hold expiró, liberar y continuar
$this->releaseHold($phoneNumber);
}
}
// Si existe una solicitud de asesor (por parte del usuario) y aún no ha respondido un asesor, respetar la espera
if (!empty($user['advisor_requested']) && $user['advisor_requested']) {
$now = time();
$until = !empty($user['bot_paused_until']) ? strtotime($user['bot_paused_until']) : null;
if ($until && $until > $now) {
try {
$this->whatsappService->sendTextMessage($phoneNumber, "🔔 Tu solicitud de asesor está pendiente. Un miembro del equipo te atenderá en breve. Si no, podrás usar *menu* después de " . date('H:i', $until) . ".");
} catch (Exception $e) {
// ignore
}
error_log("[BotService] processMessage - returning early because advisor_requested active for user {$user['id']}");
return;
} else {
// expiró la espera: limpiar flag
try {
$this->db->update('users', ['advisor_requested' => 0], 'phone_number = :phone', ['phone' => $phoneNumber]);
} catch (Exception $e) {
error_log('Failed to clear advisor_requested on expiry: ' . $e->getMessage());
}
}
}
// Si el bot está pausado por bot_paused_until, no responder automáticamente
if (!empty($user['bot_paused_until']) && strtotime($user['bot_paused_until']) > time()) {
return;
}
// Si el usuario ha desactivado el bot manualmente (toggle), no responder
if (isset($user['bot_enabled']) && !$user['bot_enabled']) {
return;
}
// Si el asesor ya respondió después del último mensaje del usuario, frenar al bot
$lastIncoming = $this->db->fetch("SELECT created_at FROM conversations WHERE user_id = :uid AND direction = 'incoming' ORDER BY created_at DESC LIMIT 1", ['uid' => $user['id']]);
$lastOutgoing = $this->db->fetch("SELECT created_at FROM conversations WHERE user_id = :uid AND direction = 'outgoing' ORDER BY created_at DESC LIMIT 1", ['uid' => $user['id']]);
if ($lastOutgoing && $lastIncoming && strtotime($lastOutgoing['created_at']) >= strtotime($lastIncoming['created_at'])) {
// El asesor ya respondió, no enviar respuestas automáticas
return;
}
// Verificar si el usuario está en un menú
if ($user['current_menu_id']) {
$this->processMenuSelection($user, $messageText);
} else {
// No usar las "respuestas rápidas" (autoresponses) automáticamente desde el bot.
// Estas respuestas solo se mostrarán en la UI del chat como "respuestas rápidas".
$this->sendDefaultNoMatch($phoneNumber);
}
}
/**
* Verificar si es un nuevo usuario
*/
private function isNewUser($userId) {
$messageCount = $this->db->fetch(
"SELECT COUNT(*) as count FROM conversations WHERE user_id = :user_id",
['user_id' => $userId]
);
// Evitar reenviar welcome si ya se envió
$user = $this->db->fetch("SELECT welcome_sent_at FROM users WHERE id = :id", ['id' => $userId]);
$welcomeSent = !empty($user['welcome_sent_at']);
return ($messageCount['count'] <= 1) && !$welcomeSent; // Solo el mensaje actual y si no se ha enviado welcome
}
/**
* Enviar mensaje de bienvenida
*/
public function sendWelcomeMessage($phoneNumber) {
$enabled = getConfigFromDB('welcome_enabled', '1');
$welcomeMessage = getConfigFromDB('welcome_message', '¡Hola! 👋 Bienvenido a nuestro servicio automatizado. Escribe *menu* para ver las opciones disponibles.');
if (!$enabled || !$welcomeMessage) return;
$this->whatsappService->sendTextMessage($phoneNumber, $welcomeMessage);
// Registrar que se envió el welcome
try {
$this->db->update('users', ['welcome_sent_at' => date('Y-m-d H:i:s')], 'phone_number = :phone', ['phone' => $phoneNumber]);
} catch (Exception $e) {
error_log('Failed to set welcome_sent_at for ' . $phoneNumber . ': ' . $e->getMessage());
}
}
/**
* Procesar comandos especiales
*/
private function processSpecialCommands($phoneNumber, $messageText) {
$command = strtolower($messageText);
switch ($command) {
case 'menu':
case 'menú':
case 'inicio':
$this->showMainMenu($phoneNumber);
return true;
case 'salir':
case 'exit':
case 'cancelar':
$this->exitMenu($phoneNumber);
return true;
case 'asesor':
case 'ayuda':
// Solicitar asesor: marcar solicitud y pausar brevemente (5 minutos)
$this->requestAdvisor($phoneNumber, 5);
return true;
}
return false;
}
/**
* Mostrar menú principal
*/
private function showMainMenu($phoneNumber) {
$mainMenu = $this->db->fetch(
"SELECT * FROM menus WHERE is_root = 1 AND is_active = 1 ORDER BY order_position LIMIT 1"
);
if ($mainMenu) {
$this->showMenu($phoneNumber, $mainMenu['id']);
} else {
$this->whatsappService->sendTextMessage(
$phoneNumber,
"❌ No hay menús configurados. Contacta con soporte."
);
}
}
/**
* Mostrar menú específico
*/
private function showMenu($phoneNumber, $menuId) {
// Obtener información del menú
$menu = $this->db->fetch(
"SELECT * FROM menus WHERE id = :id AND is_active = 1",
['id' => $menuId]
);
if (!$menu) {
$this->whatsappService->sendTextMessage(
$phoneNumber,
"❌ Menú no encontrado."
);
return;
}
// Obtener opciones del menú
$options = $this->db->fetchAll(
"SELECT * FROM menu_options WHERE menu_id = :menu_id AND is_active = 1 ORDER BY option_number",
['menu_id' => $menuId]
);
if (empty($options)) {
$this->whatsappService->sendTextMessage(
$phoneNumber,
"❌ Este menú no tiene opciones configuradas."
);
return;
}
// Construir mensaje del menú
$menuText = "📋 *" . $menu['title'] . "*\n\n";
if ($menu['description']) {
$menuText .= $menu['description'] . "\n\n";
}
foreach ($options as $option) {
$menuText .= $option['option_number'] . ". " . $option['text'] . "\n";
}
$menuText .= "\n💬 *Responde con el número de la opción que deseas*";
// Actualizar estado del usuario
$this->updateUserMenuState($phoneNumber, $menuId);
// Enviar mensaje
$this->whatsappService->sendTextMessage($phoneNumber, $menuText);
}
/**
* Procesar selección de menú
*/
private function processMenuSelection($user, $messageText) {
$phoneNumber = $user['phone_number'];
$currentMenuId = $user['current_menu_id'];
// DEBUG
try { error_log("[BotService] processMenuSelection - user_id={$user['id']} menu_id={$currentMenuId} received='{$messageText}'"); } catch (Throwable $t) {}
// Verificar si es un número
if (!is_numeric($messageText)) {
$this->whatsappService->sendTextMessage(
$phoneNumber,
"❌ Por favor, responde solo con el número de la opción deseada."
);
error_log("[BotService] processMenuSelection - non-numeric response from user {$user['id']}: '{$messageText}'");
return;
}
$optionNumber = (int)$messageText;
// Buscar la opción seleccionada
$option = $this->db->fetch(
"SELECT * FROM menu_options WHERE menu_id = :menu_id AND option_number = :option_number AND is_active = 1",
['menu_id' => $currentMenuId, 'option_number' => $optionNumber]
);
if (!$option) {
$this->whatsappService->sendTextMessage(
$phoneNumber,
"❌ Opción inválida. Por favor, selecciona una opción válida del menú."
);
error_log("[BotService] processMenuSelection - no option found for user {$user['id']} menu={$currentMenuId} option={$optionNumber}");
return;
}
error_log("[BotService] processMenuSelection - option found for user {$user['id']} menu={$currentMenuId} option={$optionNumber} action={$option['action_type']}");
// Procesar acción de la opción
$this->processMenuAction($phoneNumber, $option);
}
/**
* Procesar acción del menú
*/
private function processMenuAction($phoneNumber, $option) {
switch ($option['action_type']) {
case 'menu':
// Navegar a otro menú
$targetMenu = $this->db->fetch(
"SELECT * FROM menus WHERE name = :name AND is_active = 1",
['name' => $option['action_value']]
);
if ($targetMenu) {
$this->showMenu($phoneNumber, $targetMenu['id']);
} else {
$this->whatsappService->sendTextMessage(
$phoneNumber,
"❌ Menú no encontrado: " . $option['action_value']
);
}
break;
case 'message':
// Enviar mensaje de respuesta
$this->whatsappService->sendTextMessage($phoneNumber, $option['action_value']);
$this->exitMenu($phoneNumber);
break;
case 'api_call':
// Llamar API externa (implementar según necesidad)
$this->processApiCall($phoneNumber, $option['action_value']);
break;
case 'end':
// Finalizar conversación
$message = $option['action_value'] ?: "Gracias por usar nuestro servicio. ¡Hasta pronto!";
$this->whatsappService->sendTextMessage($phoneNumber, $message);
$this->exitMenu($phoneNumber);
// Pausar el bot para esta conversación por 5 minutos (antes eran 4 horas)
$this->pauseConversationForMinutes($phoneNumber, 5);
break;
}
}
/**
* Procesar llamada API externa
*/
private function processApiCall($phoneNumber, $apiEndpoint) {
// Aquí puedes implementar llamadas a APIs externas
// Por ejemplo, consultar saldos, procesar pagos, etc.
$this->whatsappService->sendTextMessage(
$phoneNumber,
"🔄 Procesando tu solicitud... Un momento por favor."
);
// Simular procesamiento
sleep(2);
$this->whatsappService->sendTextMessage(
$phoneNumber,
"✅ Tu solicitud ha sido procesada exitosamente."
);
$this->exitMenu($phoneNumber);
}
/**
* Procesar respuestas automáticas
*/
private function processAutoResponses($phoneNumber, $messageText) {
$keyword = strtolower($messageText);
// Buscar respuesta automática por keyword
$autoResponse = $this->db->fetch(
"SELECT * FROM autoresponses WHERE trigger_type = 'keyword' AND LOWER(trigger_value) = :keyword AND is_active = 1",
['keyword' => $keyword]
);
if ($autoResponse) {
$this->whatsappService->sendTextMessage($phoneNumber, $autoResponse['response_text']);
// Si la keyword es 'menu', mostrar el menú
if ($keyword === 'menu' || $keyword === 'menú') {
$this->showMainMenu($phoneNumber);
}
} else {
// Respuesta por defecto para mensajes no reconocidos cuando se llama explícitamente
$this->sendDefaultNoMatch($phoneNumber);
}
}
/**
* Enviar mensaje por defecto cuando no hay coincidencias
*/
private function sendDefaultNoMatch($phoneNumber) {
$defaultMessage = "🤖 No entendí tu mensaje. Escribe *menu* para ver las opciones disponibles o *ayuda* para obtener ayuda.";
$this->whatsappService->sendTextMessage($phoneNumber, $defaultMessage);
}
/**
* Salir del menú actual
*/
private function exitMenu($phoneNumber) {
$this->db->update(
'users',
['current_menu_id' => null, 'current_step' => 0, 'session_data' => null],
'phone_number = :phone',
['phone' => $phoneNumber]
);
}
/**
* Poner la conversación en pausa por X minutos (llamada desde opciones 'end')
*/
private function pauseConversationForMinutes($phoneNumber, $minutes = 5) {
try {
$pausedUntil = date('Y-m-d H:i:s', strtotime("+{$minutes} minutes"));
$this->db->update('users', ['bot_paused_until' => $pausedUntil], 'phone_number = :phone', ['phone' => $phoneNumber]);
if (function_exists('writeLog')) writeLog('INFO', "Bot paused for {$minutes} minutes for {$phoneNumber}");
} catch (Exception $e) {
error_log('pauseConversationForMinutes failed: ' . $e->getMessage());
}
}
// Compatibilidad: wrapper que acepta horas para llamadas antiguas
private function pauseConversationForHours($phoneNumber, $hours = 4) {
$minutes = intval($hours * 60);
$this->pauseConversationForMinutes($phoneNumber, $minutes);
}
/**
* Poner conversación en espera (asesor solicitado) por X minutos
*/
public function putOnHold($phoneNumber, $minutes = 5) {
try {
$pausedUntil = date('Y-m-d H:i:s', strtotime("+{$minutes} minutes"));
// Guardar bandera y tiempo de expiración (usamos bot_paused_until que ya existe)
$this->db->update('users', ['on_hold' => 1, 'bot_paused_until' => $pausedUntil, 'advisor_requested' => 0], 'phone_number = :phone', ['phone' => $phoneNumber]);
$this->whatsappService->sendTextMessage($phoneNumber, "🔔 Se ha solicitado un asesor. Te hemos puesto en espera por {$minutes} minutos. Si no hay respuesta, podrás volver a usar *menu*." );
if (function_exists('writeLog')) writeLog('INFO', "Advisor requested for $phoneNumber until $pausedUntil");
} catch (Exception $e) {
error_log('putOnHold failed: ' . $e->getMessage());
}
}
/**
* Registrar solicitud de asesor sin poner on_hold (usuario lo solicita, se notifica al equipo)
*/
public function requestAdvisor($phoneNumber, $minutes = 5) {
try {
$pausedUntil = date('Y-m-d H:i:s', strtotime("+{$minutes} minutes"));
// No ponemos on_hold para evitar bloqueo automático; usamos advisor_requested
$this->db->update('users', ['advisor_requested' => 1, 'bot_paused_until' => $pausedUntil], 'phone_number = :phone', ['phone' => $phoneNumber]);
$this->whatsappService->sendTextMessage($phoneNumber, "🔔 Hemos registrado tu solicitud de asesor. Estaremos atentos y te responderemos en breve. Si no hay respuesta, podrás volver a usar *menu* después de {$minutes} minutos.");
if (function_exists('writeLog')) writeLog('INFO', "Advisor solicited for $phoneNumber until $pausedUntil");
} catch (Exception $e) {
error_log('requestAdvisor failed: ' . $e->getMessage());
}
}
public function releaseHold($phoneNumber) {
try {
$this->db->update('users', ['on_hold' => 0, 'bot_paused_until' => null, 'advisor_requested' => 0], 'phone_number = :phone', ['phone' => $phoneNumber]);
$this->whatsappService->sendTextMessage($phoneNumber, "🔔 Tu conversación ha sido retomada por el equipo. Puedes usar *menu* para continuar.");
if (function_exists('writeLog')) writeLog('INFO', "Advisor released hold for $phoneNumber");
} catch (Exception $e) {
error_log('releaseHold failed: ' . $e->getMessage());
}
}
/**
* Actualizar estado del menú del usuario
*/
private function updateUserMenuState($phoneNumber, $menuId) {
$this->db->update(
'users',
['current_menu_id' => $menuId, 'current_step' => 1],
'phone_number = :phone',
['phone' => $phoneNumber]
);
}
/**
* Obtener estadísticas del bot
*/
public function getBotStats() {
$stats = [];
// Total de usuarios
$stats['total_users'] = $this->db->fetch(
"SELECT COUNT(*) as count FROM users"
)['count'];
// Usuarios activos (último mes)
$stats['active_users'] = $this->db->fetch(
"SELECT COUNT(DISTINCT user_id) as count FROM conversations
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"
)['count'];
// Total de mensajes
$stats['total_conversations'] = $this->db->fetch(
"SELECT COUNT(*) as count FROM conversations"
)['count'];
// Mensajes hoy
$stats['conversations_today'] = $this->db->fetch(
"SELECT COUNT(*) as count FROM conversations
WHERE DATE(created_at) = CURDATE()"
)['count'];
return $stats;
}
}
?>