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)) { // NO enviar respuesta al usuario (para conservar el indicador de atención en la lista). // En su lugar, marcar la conversación como que requiere atención y crear un mensaje 'system' try { $this->db->update('users', ['advisor_requested' => 1], 'phone_number = :phone', ['phone' => $phoneNumber]); $sys = [ 'system' => 'attention', 'type' => 'user_sent_documents', 'text' => 'El usuario indicó que ha enviado documentos o información; requiere atención.' ]; $this->db->insert('conversations', [ 'user_id' => $user['id'], 'direction' => 'incoming', 'message_type' => 'text', 'content' => json_encode($sys), 'status' => 'received', 'created_at' => date('Y-m-d H:i:s') ]); } catch (Exception $e) { error_log('[BotService] failed to mark advisor_requested on envio: ' . $e->getMessage()); } error_log("[BotService] processMessage - marked advisor_requested for user {$user['id']} (no outgoing message sent)"); 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, "⚠️ Se le ha puesto en espera por un asesor. Estará en espera hasta " . date('g:i a', $until) . ". Si no hay respuesta, podrá 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) { // Permitir *navegación explícita* durante la espera: MENU, ATRÁS, números (opciones) $normalized = mb_strtolower(trim($messageText)); $isNav = in_array($normalized, ['menu','menú','inicio','atras','atrás','volver','back'], true) || is_numeric($messageText); try { $this->whatsappService->sendTextMessage($phoneNumber, "🔔 Su solicitud de asesor está pendiente. Un miembro del equipo le atenderá en breve. Si no hay respuesta, podrá usar *MENU* después de " . date('g:i a', $until) . "."); } catch (Exception $e) { // ignore } if ($isNav) { try { error_log("[BotService] processMessage - advisor_requested active but navigation input allowed for user {$user['id']} message='{$messageText}'"); } catch (Throwable $t) {} // Manejar la navegación inmediatamente (no esperar al flujo normal) $normalized = mb_strtolower(trim($messageText)); // MENU if (in_array($normalized, ['menu','menú','inicio'], true)) { $this->showMainMenu($phoneNumber); return; } // ATRÁS if (in_array($normalized, ['atras','atrás','volver','back'], true)) { // Si hay menú actual, intentar ir al padre, si no, usar historial try { $cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]); $currentMenuId = $cur ? $cur['current_menu_id'] : null; } catch (Exception $e) { $currentMenuId = null; } if (!empty($currentMenuId)) { $this->goToParentMenu($phoneNumber, $currentMenuId); } else { $this->goToPreviousMenu($phoneNumber); } return; } // Si es número, intentar procesarlo contra current_menu_id o historial if (is_numeric(trim($messageText))) { try { $stateSvc2 = new ConversationStateService(); $resolvedMenuId2 = $stateSvc2->getCurrentMenuId($phoneNumber); } catch (Exception $e) { $resolvedMenuId2 = null; } if (empty($resolvedMenuId2)) { try { $cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]); $resolvedMenuId2 = $cur ? $cur['current_menu_id'] : null; } catch (Exception $e) { $resolvedMenuId2 = null; } } // Si no hay menú activo, intentar con historial if (empty($resolvedMenuId2)) { try { $hist = (array)$stateSvc2->getStateData($phoneNumber, 'menu_history'); if (!empty($hist)) $resolvedMenuId2 = end($hist); } catch (Exception $e) { $resolvedMenuId2 = null; } } if (!empty($resolvedMenuId2)) { // Asegurar que la BD refleje el menu activo para que processMenuSelection lo resuelva correctamente try { $this->db->update('users', ['current_menu_id' => $resolvedMenuId2, 'current_step' => 1], 'phone_number = :phone', ['phone' => $phoneNumber]); try { $stateSvc2->setCurrentMenu($phoneNumber, (int)$resolvedMenuId2); } catch (Exception $e) { // ignore } } catch (Exception $e) { error_log('[BotService] failed to set current_menu_id before processing numeric selection: ' . $e->getMessage()); } $user['current_menu_id'] = $resolvedMenuId2; $this->processMenuSelection($user, $messageText); return; } } // Si llegamos aquí no pudimos resolver navegación; permitir que el flujo normal lo trate } else { 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()) { // Allow explicit navigation commands and numeric menu selection during pause (better UX) $normalized = mb_strtolower(trim($messageText)); $isNav = in_array($normalized, ['menu','menú','inicio','atras','atrás','volver','back'], true) || is_numeric(trim($messageText)); if ($isNav) { try { error_log("[BotService] processMessage - bot_paused_until active but allowing navigation input for phone={$phoneNumber} message='{$messageText}'"); } catch (Throwable $t) {} // Si es un número, procesarlo inmediatamente como selección de menú if (is_numeric(trim($messageText))) { try { $stateSvc2 = new ConversationStateService(); $resolvedMenuId2 = $stateSvc2->getCurrentMenuId($phoneNumber); } catch (Exception $e) { $resolvedMenuId2 = null; } if (empty($resolvedMenuId2)) { try { $cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]); $resolvedMenuId2 = $cur ? $cur['current_menu_id'] : null; } catch (Exception $e) { $resolvedMenuId2 = null; } } // Si aún no hay menú activo, intentar con historial if (empty($resolvedMenuId2)) { try { $hist = (array)$stateSvc2->getStateData($phoneNumber, 'menu_history'); if (!empty($hist)) $resolvedMenuId2 = end($hist); } catch (Exception $e) { $resolvedMenuId2 = null; } } if (!empty($resolvedMenuId2)) { // Asegurar que la BD refleje el menu activo para que processMenuSelection lo resuelva correctamente try { $this->db->update('users', ['current_menu_id' => $resolvedMenuId2, 'current_step' => 1], 'phone_number = :phone', ['phone' => $phoneNumber]); try { $stateSvc2->setCurrentMenu($phoneNumber, (int)$resolvedMenuId2); } catch (Exception $e) { // ignore } } catch (Exception $e) { error_log('[BotService] failed to set current_menu_id before processing numeric selection during pause: ' . $e->getMessage()); } $user['current_menu_id'] = $resolvedMenuId2; $this->processMenuSelection($user, $messageText); return; } } // proceed to let navigation/menus be processed } else { 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']}"); } // Resolver current_menu_id fresco (evitar usar $user desactualizado que viene del webhook) $resolvedMenuId = null; try { $stateSvc = new ConversationStateService(); $resolvedMenuId = $stateSvc->getCurrentMenuId($phoneNumber); } catch (Exception $e) { // ignore } if (empty($resolvedMenuId)) { try { $cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]); $resolvedMenuId = $cur ? $cur['current_menu_id'] : ($user['current_menu_id'] ?? null); } catch (Exception $e) { $resolvedMenuId = $user['current_menu_id'] ?? null; } } // Actualizar el array $user para que funciones posteriores lo usen si es necesario $user['current_menu_id'] = $resolvedMenuId; try { error_log("[BotService] processMessage - resolved current_menu_id={$resolvedMenuId} for user {$user['id']} phone={$phoneNumber}"); } catch (Throwable $t) {} // Verificar si el usuario está en un menú if (!empty($resolvedMenuId)) { $this->processMenuSelection($user, $messageText); return; } // Si no hay menú actual pero el usuario envía un número, intentar interpretar el número // contra el último menú en el historial (por ejemplo: user vio opciones, eligió end y luego envía '6' para volver) if (is_numeric(trim($messageText))) { try { $stateSvc = new ConversationStateService(); $history = (array)$stateSvc->getStateData($phoneNumber, 'menu_history'); if (!empty($history)) { $lastMenu = end($history); try { error_log("[BotService] interpreting numeric input '{$messageText}' against lastMenu={$lastMenu} for phone={$phoneNumber}"); } catch (Throwable $t) {} // simular que estamos en ese menú para procesar la selección $user['current_menu_id'] = $lastMenu; $this->processMenuSelection($user, $messageText); return; } } catch (Exception $e) { // ignore and fallthrough to default error_log('[BotService] failed to check menu_history: ' . $e->getMessage()); } } // 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. Escriba *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]); // Asegurar que este envío no sea interpretado como respuesta de un asesor: // Limpiar banderas de in_service / advisor_requested y evitar que la UI marque la conversación como atendida. try { $this->db->update('users', ['in_service' => 0, 'in_service_by' => null, 'advisor_requested' => 0], 'phone_number = :phone', ['phone' => $phoneNumber]); } catch (Exception $e) { error_log('Failed to clear in_service/advisor flags after welcome: ' . $e->getMessage()); } } 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); try { error_log("[BotService] processSpecialCommands - command={$command} phone={$phoneNumber}"); } catch (Throwable $t) {} switch ($command) { case 'menu': case 'menú': case 'inicio': try { error_log("[BotService] processSpecialCommands - detected MENU command, calling showMainMenu for phone={$phoneNumber}"); } catch (Throwable $t) {} $this->showMainMenu($phoneNumber); return true; case 'salir': case 'exit': case 'cancelar': $this->exitMenu($phoneNumber); return true; case 'atras': case 'atrás': case 'volver': case 'back': try { error_log("[BotService] processSpecialCommands - detected BACK command in global handler for phone={$phoneNumber}"); } catch (Throwable $t) {} // Resolver current_menu_id desde users y decidir la acción más adecuada try { $cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]); $currentMenuId = $cur ? $cur['current_menu_id'] : null; } catch (Exception $e) { $currentMenuId = null; } if (!empty($currentMenuId)) { $this->goToParentMenu($phoneNumber, $currentMenuId); } else { // Intentar usar historial $this->goToPreviousMenu($phoneNumber); } return true; case 'asesor': case 'ayuda': // Solicitar asesor: marcar solicitud y pausar brevemente (3 minutos) $this->requestAdvisor($phoneNumber, 3); return true; } return false; } /** * Mostrar menú principal */ private function showMainMenu($phoneNumber) { try { error_log("[BotService] showMainMenu - phone={$phoneNumber} fetching root menu"); } catch (Throwable $t) {} $mainMenu = $this->db->fetch( "SELECT * FROM menus WHERE is_root = 1 AND is_active = 1 ORDER BY order_position LIMIT 1" ); try { error_log("[BotService] showMainMenu - fetched root menu=" . json_encode($mainMenu)); } catch (Throwable $t) {} if ($mainMenu) { $this->showMenu($phoneNumber, $mainMenu['id']); } else { try { error_log("[BotService] showMainMenu - no root menu found for phone={$phoneNumber}"); } catch (Throwable $t) {} $this->whatsappService->sendTextMessage( $phoneNumber, "❌ No hay menús configurados. Contacte con soporte." ); } } /** * Mostrar menú específico */ private function showMenu($phoneNumber, $menuId) { try { error_log("[BotService] showMenu - phone={$phoneNumber} menuId={$menuId} fetching menu"); } catch (Throwable $t) {} // Obtener información del menú $menu = $this->db->fetch( "SELECT * FROM menus WHERE id = :id AND is_active = 1", ['id' => $menuId] ); try { error_log("[BotService] showMenu - fetched menu=" . json_encode($menu)); } catch (Throwable $t) {} if (!$menu) { try { error_log("[BotService] showMenu - menu not found id={$menuId} phone={$phoneNumber}"); } catch (Throwable $t) {} $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] ); try { error_log("[BotService] showMenu - menuId={$menuId} options_count=" . count($options)); } catch (Throwable $t) {} 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"; } // Añadir opción "Atrás" si el menú tiene un menú padre if (!empty($menu['parent_id'])) { $menuText .= "0. Atrás\n"; } $menuText .= "\n💬 *Responda con el número de la opción que desea*"; // 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 = null; // Intentar primero el servicio de estados (si existe) — esto permite flujos que usan user_states en lugar de la columna users.current_menu_id try { $stateSvc = new ConversationStateService(); $currentMenuId = $stateSvc->getCurrentMenuId($phoneNumber); } catch (Exception $e) { // ignore if service not available } // Si no tenemos valor desde el servicio de estados, consultar columna users if (empty($currentMenuId)) { $cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]); $currentMenuId = $cur ? $cur['current_menu_id'] : ($user['current_menu_id'] ?? null); } // DEBUG try { error_log("[BotService] processMenuSelection - RESOLVED menu_id={$currentMenuId} for phone={$phoneNumber} user_id={$user['id']} incoming='{$messageText}'"); } catch (Throwable $t) {} // Soportar comando "atrás" o "volver" para navegar al menú padre o al menú previo $cmd = mb_strtolower(trim($messageText)); $backCommands = ['atras', 'atrás', 'volver', 'back']; if (in_array($cmd, $backCommands, true)) { try { error_log("[BotService] processMenuSelection - BACK command detected for phone={$phoneNumber} resolved_menu={$currentMenuId}"); } catch (Throwable $t) {} if (!empty($currentMenuId)) { $this->goToParentMenu($phoneNumber, $currentMenuId); } else { // Si no hay menú actual, intentar volver al menú previo almacenado en historial $this->goToPreviousMenu($phoneNumber); } return; } // Verificar si es un número if (!is_numeric($messageText)) { $this->whatsappService->sendTextMessage( $phoneNumber, "❌ Por favor, responda solo con el número de la opción deseada, la palabra *MENU* o *ATRÁS* para volver." ); error_log("[BotService] processMenuSelection - non-numeric response from user {$user['id']}: '{$messageText}'"); return; } $optionNumber = (int)$messageText; // Si eligió 0 => volver al menú padre if ($optionNumber === 0) { $this->goToParentMenu($phoneNumber, $currentMenuId); return; } // 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, seleccione 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 3 minutos (antes eran 4 horas) $this->pauseConversationForMinutes($phoneNumber, 3); 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 su solicitud... Un momento, por favor." ); // Simular procesamiento sleep(2); $this->whatsappService->sendTextMessage( $phoneNumber, "✅ Su solicitud ha sido procesada con éxito." ); $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í su mensaje. Escriba *MENU* para ver las opciones disponibles o *ASESOR* para obtener ayuda."; $this->whatsappService->sendTextMessage($phoneNumber, $defaultMessage); } /** * Salir del menú actual * Guarda el menú previo en el historial para permitir "ATRÁS" después de salir */ private function exitMenu($phoneNumber) { // Obtener menú actual antes de limpiar try { $cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]); $previousMenu = $cur ? $cur['current_menu_id'] : null; if (!empty($previousMenu)) { try { $this->pushMenuToHistory($phoneNumber, $previousMenu); } catch (Exception $e) { error_log('[BotService] exitMenu - failed to push previous menu: ' . $e->getMessage()); } } } catch (Exception $e) { // ignore } $this->db->update( 'users', ['current_menu_id' => null, 'current_step' => 0, 'session_data' => null], 'phone_number = :phone', ['phone' => $phoneNumber] ); // Mantener el estado en ConversationStateService (no borrar el historial) try { $stateSvc = new ConversationStateService(); $stateSvc->setCurrentMenu($phoneNumber, null); } catch (Exception $e) { // ignore } } /** * Poner la conversación en pausa por X minutos (llamada desde opciones 'end') */ private function pauseConversationForMinutes($phoneNumber, $minutes = 3) { 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 = 3) { 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' => 1], 'phone_number = :phone', ['phone' => $phoneNumber]); // Enviar mensaje (bot) marcando la solicitud; no incluimos metadata de operador para evitar limpiar la marca $this->whatsappService->sendTextMessage($phoneNumber, "🔔 Se ha solicitado un asesor. Se le ha puesto en espera por {$minutes} minutos. Si no hay respuesta, podrá 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 = 3) { 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, "🔔 Le hemos transferido a un asesor; le 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 { // Clear hold/advisor flags and also ensure in_service is cleared for consistency $this->db->update('users', ['on_hold' => 0, 'bot_paused_until' => null, 'advisor_requested' => 0, 'in_service' => 0, 'in_service_by' => null, 'in_service_at' => null], 'phone_number = :phone', ['phone' => $phoneNumber]); $this->whatsappService->sendTextMessage($phoneNumber, "🔔 Su conversación ha sido retomada por el equipo. Puede 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 su conversación. Por favor, espere su respuesta."); // ignore send errors } catch (Exception $e) { // Log and continue if notification fails error_log('attendConversation notify failed: ' . $e->getMessage()); } // 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}"); // Ensure consistent behavior: release hold / clear flags when finishing attend try { $this->releaseHold($phoneNumber); } catch (Exception $e) { error_log('finishAttendConversation: releaseHold failed: ' . $e->getMessage()); } } catch (Exception $e) { error_log('finishAttendConversation failed: ' . $e->getMessage()); throw $e; } } /** * Ir al menú padre del menú actual */ private function goToParentMenu($phoneNumber, $currentMenuId) { try { $parentId = null; $menu = null; // Intentar resolver por ID numérico if (!empty($currentMenuId) && is_numeric($currentMenuId)) { $menu = $this->db->fetch("SELECT id, parent_id, name, title FROM menus WHERE id = :id", ['id' => (int)$currentMenuId]); } // Si no existe, intentar resolver por nombre (caso action_value = 'main_menu') if (!$menu && !empty($currentMenuId)) { $menu = $this->db->fetch("SELECT id, parent_id, name, title FROM menus WHERE name = :name", ['name' => $currentMenuId]); } if ($menu) { $parentId = $menu['parent_id'] ?? null; try { error_log("[BotService] goToParentMenu - resolved menu id={$menu['id']} name={$menu['name']} title='{$menu['title']}' parent_id=" . ($parentId ?? 'null') . " for phone={$phoneNumber}"); } catch (Throwable $t) {} if (!empty($parentId)) { $this->showMenu($phoneNumber, $parentId); return; } else { // Si no hay padre, re-mostrar el menú actual (mejor UX) if (!empty($menu['id'])) { $this->showMenu($phoneNumber, $menu['id']); return; } } } else { try { error_log("[BotService] goToParentMenu - could not resolve menu for currentMenuId='{$currentMenuId}' phone={$phoneNumber}"); } catch (Throwable $t) {} } // Fallback final a menú principal $this->showMainMenu($phoneNumber); } catch (Exception $e) { error_log('[BotService] goToParentMenu failed: ' . $e->getMessage()); $this->showMainMenu($phoneNumber); } } /** * Actualizar estado del menú del usuario * - Guarda el menú actual en el historial (ConversationStateService) antes de actualizar */ private function updateUserMenuState($phoneNumber, $menuId) { // Obtener menú previo try { $cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]); $previousMenu = $cur ? $cur['current_menu_id'] : null; } catch (Exception $e) { $previousMenu = null; } try { error_log("[BotService] updateUserMenuState - phone={$phoneNumber} previousMenu=" . json_encode($previousMenu) . " newMenu=" . json_encode($menuId)); } catch (Throwable $t) {} // Si hay un menú previo diferente al nuevo, empujarlo al historial if (!empty($previousMenu) && $previousMenu != $menuId) { try { $this->pushMenuToHistory($phoneNumber, $previousMenu); } catch (Exception $e) { error_log('[BotService] failed to push menu to history: ' . $e->getMessage()); } } // Actualizar columna users $this->db->update( 'users', ['current_menu_id' => $menuId, 'current_step' => 1], 'phone_number = :phone', ['phone' => $phoneNumber] ); // También actualizar ConversationStateService por compatibilidad try { $stateSvc = new ConversationStateService(); $stateSvc->setCurrentMenu($phoneNumber, (int)$menuId); } catch (Exception $e) { // ignore if service not available } } /** * Empujar un menú al historial del usuario (se almacena en ConversationStateService.state_data.menu_history) */ private function pushMenuToHistory(string $phoneNumber, $menuId) { try { $stateSvc = new ConversationStateService(); $currentState = $stateSvc->getState($phoneNumber); // Si no existe una fila de estado, crearla con el historial inicial if (!$currentState) { try { error_log("[BotService] pushMenuToHistory - no existing state, creating initial state with menu_history for phone={$phoneNumber} menuId={$menuId}"); $res = $stateSvc->setState($phoneNumber, ConversationStateService::STATE_INITIAL, ['menu_history' => [$menuId]]); error_log('[BotService] pushMenuToHistory - setState result: ' . json_encode($res)); return $res; } catch (Exception $e) { error_log('[BotService] pushMenuToHistory create initial state failed: ' . $e->getMessage()); return false; } } $history = (array)$stateSvc->getStateData($phoneNumber, 'menu_history'); if (!is_array($history)) $history = []; // Añadir al final y limitar a 10 entradas $history[] = $menuId; if (count($history) > 10) array_shift($history); return $stateSvc->updateStateData($phoneNumber, ['menu_history' => $history]); } catch (Exception $e) { error_log('[BotService] pushMenuToHistory failed: ' . $e->getMessage()); return false; } } /** * Sacar el último menú del historial (LIFO) y retornarlo */ private function popMenuFromHistory(string $phoneNumber) { try { $stateSvc = new ConversationStateService(); $history = (array)$stateSvc->getStateData($phoneNumber, 'menu_history'); if (!is_array($history) || empty($history)) return null; $last = array_pop($history); $stateSvc->updateStateData($phoneNumber, ['menu_history' => $history]); return $last; } catch (Exception $e) { error_log('[BotService] popMenuFromHistory failed: ' . $e->getMessage()); return null; } } /** * Ir al menú anterior según historial. Si no hay historial, fallback a main menu. */ private function goToPreviousMenu($phoneNumber) { $prev = $this->popMenuFromHistory($phoneNumber); if (!empty($prev)) { // Intentar mostrar por ID primero if (is_numeric($prev)) { $this->showMenu($phoneNumber, (int)$prev); return; } // Si no es numérico, intentar resolver por nombre $menu = $this->db->fetch("SELECT id FROM menus WHERE name = :name", ['name' => $prev]); if ($menu && !empty($menu['id'])) { $this->showMenu($phoneNumber, $menu['id']); return; } } // Fallback $this->showMainMenu($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; } } ?>