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']; // Comprobar en la base de datos el estado más reciente de 'in_service' para evitar condiciones de carrera try { $fresh = $this->db->fetch("SELECT in_service, in_service_by FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]); if (!empty($fresh['in_service'])) { try { $userId = $user['id'] ?? 'unknown'; error_log("[BotService] processMessage - user_id={$userId} is currently in_service (DB), skipping bot processing"); } catch (Throwable $t) { // ignore logging errors } return; } // actualizar el array $user con valores frescos por si se usan más adelante $user['in_service'] = !empty($fresh['in_service']); $user['in_service_by'] = $fresh['in_service_by'] ?? null; } catch (Exception $e) { error_log('[BotService] processMessage - failed to refresh in_service: ' . $e->getMessage()); // En caso de fallo al consultar, continuar con lógica previa (no bloquear) } // 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; } // Si el usuario escribe 'enviado' o 'enviados' en cualquier momento, enviar confirmación de recepción try { // Support masculino/femenino, singular/plural: enviado, enviada, enviados, enviadas if (preg_match('/\b(enviad[oa]s?)\b/iu', $messageText)) { $ack = "Estaremos procesando su solicitud y confirmaremos en unos minutos"; try { $this->whatsappService->sendTextMessage($phoneNumber, $ack); } catch (Exception $e) { // ignore send errors } error_log("[BotService] processMessage - sent 'enviados' ack to user {$user['id']}"); return; } } catch (Throwable $t) { // ignore regex errors } // 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; } // Verificar si el asesor ya respondió después del último mensaje del usuario. // En ese caso NO enviaremos respuestas automáticas, pero sí permitiremos // que el usuario interactúe con menús manualmente. $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']]); $advisorReplied = false; if ($lastOutgoing && $lastIncoming && strtotime($lastOutgoing['created_at']) >= strtotime($lastIncoming['created_at'])) { // El asesor ya respondió, marcar flag pero no retornar (permitir menús y comandos explícitos) $advisorReplied = true; error_log("[BotService] processMessage - advisorReplied=true for user {$user['id']}"); } // 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". // Si un asesor ya respondió recientemente, no enviar respuestas automáticas. if (empty($advisorReplied)) { $this->sendDefaultNoMatch($phoneNumber); } else { // Opcional: informar que el asesor ya respondió y no podemos enviar automáticas try { //$this->whatsappService->sendTextMessage($phoneNumber, "🔕 Un asesor ya ha intervenido en la conversación, por favor espera su respuesta o utiliza *menu* para ver opciones."); } catch (Exception $e) { // ignore } } } } /** * 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 o la palabra *menu* para iniciar nuevamente." ); error_log("[BotService] processMenuSelection - non-numeric response from user {$user['id']}: '{$messageText}'"); return; } $optionNumber = (int)$messageText; // Buscar la opción seleccionada por número $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] ); // Si no encontramos por número, intentar buscar por texto (caso de replies interactivos que envían título) if (!$option) { $txt = strtolower(trim($messageText)); if (!empty($txt)) { $option = $this->db->fetch( "SELECT * FROM menu_options WHERE menu_id = :menu_id AND is_active = 1 AND (LOWER(text) = :txt OR LOWER(text) LIKE :like) LIMIT 1", ['menu_id' => $currentMenuId, 'txt' => $txt, 'like' => "%{$txt}%"] ); if ($option) { error_log("[BotService] processMenuSelection - matched option by text for user {$user['id']} menu={$currentMenuId} text='{$txt}' => option_number={$option['option_number']}"); } } } 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 *asesor* 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, "🔔 Te hemos transferido con un asesor, te responderemos en breve."); 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()); } } /** * Marcar la conversación como "en servicio" por un operador (Atender) */ public function attendConversation($phoneNumber, $operatorId = null) { try { $now = date('Y-m-d H:i:s'); $update = ['in_service' => 1, 'in_service_at' => $now, 'advisor_requested' => 0]; if ($operatorId) $update['in_service_by'] = $operatorId; $this->db->update('users', $update, 'phone_number = :phone', ['phone' => $phoneNumber]); // Notificar al usuario try { $this->whatsappService->sendTextMessage($phoneNumber, "🔔 Un asesor está atendiendo tu conversación. Por favor, espera su respuesta."); } catch (Exception $e) { // ignore send errors } // Registrar actividad del operador si hay información disponible $user = $this->db->fetch('SELECT id FROM users WHERE phone_number = :phone', ['phone' => $phoneNumber]); $userId = $user['id'] ?? null; $activity = [ 'user_id' => $userId, 'operator_id' => $operatorId, 'action' => 'attend', 'details' => 'Operator started attending the conversation', 'created_at' => $now ]; try { $this->db->insert('operator_activity', $activity); } catch (Exception $e) { if (function_exists('writeLog')) writeLog('WARN', 'Failed to insert operator_activity: ' . $e->getMessage()); error_log('Failed to insert operator_activity: ' . $e->getMessage()); } if (function_exists('writeLog')) writeLog('INFO', "Operator {$operatorId} attending conversation for {$phoneNumber}"); } catch (Exception $e) { error_log('attendConversation failed: ' . $e->getMessage()); throw $e; } } /** * Finalizar la atención: limpiar estado 'in_service' y enviar plantilla de encuesta */ public function finishAttendConversation($phoneNumber, $operatorId = null) { try { // Limpiar estado de 'in_service' $this->db->update('users', ['in_service' => 0, 'in_service_by' => null, 'in_service_at' => null, 'advisor_requested' => 0], 'phone_number = :phone', ['phone' => $phoneNumber]); // Enviar plantilla de encuesta (configurable) $template = getConfigFromDB('survey_template', 'encuesta_satisfaccin'); $lang = getConfigFromDB('survey_template_language', 'es'); try { $this->whatsappService->sendTemplateMessage($phoneNumber, $template, $lang, []); } catch (Exception $e) { // Ignorar errores de envío de plantilla pero loguear error_log('Failed to send survey template: ' . $e->getMessage()); if (function_exists('writeLog')) writeLog('WARN', 'Failed to send survey template: ' . $e->getMessage()); } // Registrar actividad del operador $user = $this->db->fetch('SELECT id FROM users WHERE phone_number = :phone', ['phone' => $phoneNumber]); $userId = $user['id'] ?? null; $now = date('Y-m-d H:i:s'); $activity = [ 'user_id' => $userId, 'operator_id' => $operatorId, 'action' => 'finish', 'details' => "Finished attend and sent survey: {$template}", 'created_at' => $now ]; try { $this->db->insert('operator_activity', $activity); } catch (Exception $e) { error_log('Failed to insert operator_activity on finish: ' . $e->getMessage()); } if (function_exists('writeLog')) writeLog('INFO', "Operator {$operatorId} finished attending for {$phoneNumber}, survey={$template}"); } catch (Exception $e) { error_log('finishAttendConversation failed: ' . $e->getMessage()); throw $e; } } /** * 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; } } ?>