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']; $userId = $user['id'] ?? null; // ── Advisory lock por usuario ───────────────────────────────────────── // Serializar el procesamiento para el mismo usuario y evitar race conditions // (WhatsApp puede entregar el mismo webhook dos veces en paralelo). $lockAcquired = false; if ($userId) { $lockKey = 'bot_user_' . $userId; $lockRow = $this->db->fetch("SELECT GET_LOCK(:k, 10) as ok", ['k' => $lockKey]); $lockAcquired = !empty($lockRow['ok']); if (!$lockAcquired) { error_log("[BotService] processMessage - lock timeout para user_id={$userId}, ignorando llamada concurrente"); return; } // Recargar datos frescos del usuario después de adquirir el lock $fresh = $this->db->fetch("SELECT * FROM users WHERE id = :id", ['id' => $userId]); if ($fresh) $user = $fresh; } try { // 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') . " bot_enabled=" . (isset($user['bot_enabled']) ? $user['bot_enabled'] : 'null')); } catch (Throwable $t) { // ignore logging errors } // Si ha pasado suficiente tiempo (6 horas) desde la última actividad, reiniciar la conversación y permitir re-enviar el welcome try { if (!empty($user['id'])) { $this->maybeResetConversationAfterIdle($user['id']); } } catch (Exception $e) { error_log('[BotService] maybeResetConversationAfterIdle threw: ' . $e->getMessage()); } // ── Flujo de Términos y Condiciones ────────────────────────────────── // 1. Si el usuario está esperando respuesta de términos, procesarla primero. if (!empty($user['terms_pending'])) { if ($this->processTermsResponse($user, $phoneNumber, $messageText)) { return; } } // 2. Si el usuario necesita aceptar (nuevo, expirado, o versión nueva con forzar_reenvio). if ($this->needsTermsAcceptance($user)) { $this->sendTermsMessage($user, $phoneNumber); return; } // ───────────────────────────────────────────────────────────────────── // Verificar si es un nuevo usuario (enviar mensaje de bienvenida) // Solo se envía si ya aceptó los términos, o si no hay términos activos configurados. $termsRequired = $this->getActiveTermsVersion() !== null; $termsAccepted = !empty($user['terms_accepted_at']); if ($this->isNewUser($user['id']) && (!$termsRequired || $termsAccepted)) { $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)) { // Cuando el usuario confirma que envió la documentación, notificarle y transferir a un asesor. try { // Usar requestAdvisor para aplicar advisor_requested, pausar el bot y enviar notificación al usuario $this->requestAdvisor($phoneNumber, 3); // Registrar mensaje de sistema para trazabilidad (no altera flujo de notificación) $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 request advisor on envio: ' . $e->getMessage()); } error_log("[BotService] processMessage - requested advisor for user {$user['id']} (sent notification to user)"); 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); // No enviar notificación automática al usuario cuando advisor_requested está activa. // Mantener navegación explícita permitida (MENU/ATRÁS/números); no enviar mensajes proactivos. 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), evitar respuestas automáticas // pero permitir comandos explícitos (MENU, ATRÁS) y selección numérica dentro de menús. $botDisabled = (isset($user['bot_enabled']) && !$user['bot_enabled']); // 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']}"); try { error_log("[BotService] processMessage - lastOutgoing={$lastOutgoing['created_at']} lastIncoming={$lastIncoming['created_at']}"); } catch (Throwable $t) {} } else { try { error_log("[BotService] processMessage - advisorReplied=false lastOutgoing=" . ($lastOutgoing['created_at'] ?? 'null') . " lastIncoming=" . ($lastIncoming['created_at'] ?? 'null')) ; } catch (Throwable $t) {} } // 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)) { if (empty($botDisabled)) { $this->sendDefaultNoMatch($phoneNumber); } else { try { error_log("[BotService] processMessage - bot disabled, skipping default no-match for user {$user['id']}"); } catch (Throwable $t) {} } } 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 } } } finally { // Liberar advisory lock si fue adquirido if ($lockAcquired && isset($lockKey)) { try { $this->db->query("DO RELEASE_LOCK(:k)", ['k' => $lockKey]); } catch (Exception $e) {} } } } /** * Si la última actividad del usuario fue hace >= 6 horas, reinicia el estado de la conversación * para que el usuario vuelva a recibir la bienvenida y empiece desde cero. Esto incluye limpiar * menús, flags de espera/asesor, y `welcome_sent_at`. */ private function maybeResetConversationAfterIdle($userId) { try { $sixHours = 6 * 3600; $row = $this->db->fetch("SELECT MAX(created_at) as last_at FROM conversations WHERE user_id = :uid", ['uid' => $userId]); $lastAt = $row && !empty($row['last_at']) ? strtotime($row['last_at']) : null; if (!$lastAt) return false; if ((time() - $lastAt) >= $sixHours) { try { $this->db->update('users', [ 'current_menu_id' => null, 'current_step' => 0, 'session_data' => null, 'welcome_sent_at' => null, 'advisor_requested' => 0, 'on_hold' => 0, 'in_service' => 0, 'in_service_by' => null, 'bot_paused_until' => null ], 'id = :id', ['id' => $userId]); // Registrar mensaje sistema para trazabilidad $this->db->insert('conversations', [ 'user_id' => $userId, 'direction' => 'outgoing', 'message_type' => 'text', 'content' => json_encode(['system' => 'system', 'type' => 'session_reset', 'text' => 'Sesión reiniciada por inactividad (6h).']), 'status' => 'sent', 'created_at' => date('Y-m-d H:i:s') ]); error_log("[BotService] maybeResetConversationAfterIdle: reset session for user_id={$userId} after inactivity"); } catch (Exception $e) { error_log('[BotService] maybeResetConversationAfterIdle -> failed to reset user state: ' . $e->getMessage()); } return true; } } catch (Exception $e) { error_log('[BotService] maybeResetConversationAfterIdle failed: ' . $e->getMessage()); } return false; } // ========================================================================= // TÉRMINOS Y CONDICIONES // ========================================================================= /** * Obtiene la versión activa de términos o null si no existe ninguna. */ private function getActiveTermsVersion(): ?array { try { return $this->db->fetch( "SELECT * FROM terms_versions WHERE activa = 1 ORDER BY id DESC LIMIT 1" ) ?: null; } catch (Exception $e) { error_log('[BotService] getActiveTermsVersion error: ' . $e->getMessage()); return null; } } /** * Decide si el usuario debe aceptar (o re-aceptar) los términos. * - No hay aceptación previa (terms_accepted_at IS NULL) * - Han pasado más de 6 meses desde la última aceptación * - La versión activa tiene forzar_reenvio=1 y el usuario aceptó una versión anterior */ private function needsTermsAcceptance(array $user): bool { $terms = $this->getActiveTermsVersion(); if (!$terms) return false; // Sin versión activa → no hay términos que aceptar // ¿El usuario ya aceptó esta versión exacta? $acceptedVersionId = $user['terms_version_id'] ?? null; $acceptedAt = $user['terms_accepted_at'] ?? null; // Nunca aceptó if (!$acceptedAt) return true; // Forzar re-aceptación si la versión activa lo requiere y el usuario tiene una versión diferente if (!empty($terms['forzar_reenvio']) && (int)$acceptedVersionId !== (int)$terms['id']) { return true; } // Han pasado más de 6 meses (26 semanas) desde la última aceptación $sixMonthsAgo = strtotime('-6 months'); if (strtotime($acceptedAt) < $sixMonthsAgo) return true; return false; } /** * Envía el mensaje de términos al usuario y registra el envío en BD. */ private function sendTermsMessage(array $user, string $phoneNumber): void { $terms = $this->getActiveTermsVersion(); if (!$terms) return; // Prioridad: system_config (lo que edita el usuario en "Configuración del Sistema") // Fallback: terms_versions.mensaje_aceptacion (si el admin lo editó directamente en la BD) $msg = getConfigFromDB('terms_message', ''); if (!$msg && !empty($terms['mensaje_aceptacion'])) { $msg = $terms['mensaje_aceptacion']; } if (!$msg) return; // Adjuntar URL del documento solo si es una URL pública y no está ya incluida en el mensaje // Normalizar protocolo para la comparación (http vs https no deben importar) if (!empty($terms['documento_url'])) { $docUrl = $terms['documento_url']; $isLocal = ( strpos($docUrl, 'localhost') !== false || strpos($docUrl, '127.0.0.1') !== false || preg_match('/https?:\/\/\d+\.\d+\.\d+\.\d+:\d+/', $docUrl) ); $docUrlNormalized = preg_replace('#^https?://#', '', $docUrl); $msgNormalized = preg_replace('#https?://#', '', $msg); if (!$isLocal && strpos($msgNormalized, $docUrlNormalized) === false) { $msg .= "\n\n📄 Documento: " . $docUrl; } } $this->whatsappService->sendTextMessage($phoneNumber, $msg); // Marcar usuario como "esperando respuesta de términos" // También marcamos welcome_sent_at para evitar que una llamada concurrente // al webhook envíe el mensaje de bienvenida por race condition. try { $this->db->update( 'users', ['terms_pending' => 1, 'welcome_sent_at' => date('Y-m-d H:i:s')], 'id = :id', ['id' => $user['id']] ); } catch (Exception $e) { error_log('[BotService] sendTermsMessage – update terms_pending: ' . $e->getMessage()); } // Registrar envío en terms_acceptance con estado pendiente try { $this->db->insert('terms_acceptance', [ 'user_id' => $user['id'], 'terms_version_id' => $terms['id'], 'phone_number' => $phoneNumber, 'estado' => 'pendiente', 'fecha_envio' => date('Y-m-d H:i:s'), ]); } catch (Exception $e) { error_log('[BotService] sendTermsMessage – insert terms_acceptance: ' . $e->getMessage()); } error_log("[BotService] sendTermsMessage – sent terms v{$terms['version']} to {$phoneNumber}"); } /** * Procesa la respuesta del usuario cuando está en estado terms_pending. * Detecta "acepto" / "no acepto" / "rechazo". * Devuelve true si el mensaje fue manejado (para detener el procesamiento normal). */ private function processTermsResponse(array $user, string $phoneNumber, string $messageText): bool { $terms = $this->getActiveTermsVersion(); if (!$terms) { // Sin versión activa: limpiar el flag y continuar try { $this->db->update('users', ['terms_pending' => 0], 'id = :id', ['id' => $user['id']]); } catch (Exception $e) {} return false; } $normalized = strtolower(trim( iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $messageText) ?: $messageText )); // Detectar aceptación: "acepto", "si acepto", "sí, acepto", "1", etc. $accepted = (bool) preg_match('/\b(acepto|si acepto|si|sí|1|ok|aceptar|de acuerdo|confirmo)\b/', $normalized); // Detectar rechazo explícito: "no acepto", "rechazo", "no", "0", etc. $rejected = !$accepted && (bool) preg_match('/\b(no acepto|no|rechazo|rechazar|negar|0|rechaz[oa])\b/', $normalized); if (!$accepted && !$rejected) { // Respuesta no reconocida → volver a preguntar $retry = "No entendí tu respuesta. Por favor responde *Acepto* o *No acepto*."; $this->whatsappService->sendTextMessage($phoneNumber, $retry); return true; } $estado = $accepted ? 'aceptado' : 'rechazado'; $now = date('Y-m-d H:i:s'); // Actualizar el último registro pendiente de esta versión try { $this->db->query( "UPDATE terms_acceptance SET estado = :estado, fecha_respuesta = :now WHERE user_id = :uid AND terms_version_id = :vid AND estado = 'pendiente' ORDER BY id DESC LIMIT 1", ['estado' => $estado, 'now' => $now, 'uid' => $user['id'], 'vid' => $terms['id']] ); } catch (Exception $e) { error_log('[BotService] processTermsResponse – update acceptance: ' . $e->getMessage()); } if ($accepted) { // Actualizar usuario: aceptó términos try { $this->db->update('users', [ 'terms_pending' => 0, 'terms_accepted_at' => $now, 'terms_version_id' => $terms['id'], // Limpiar forzar_reenvio para este usuario (lo manejamos por versión) ], 'id = :id', ['id' => $user['id']]); } catch (Exception $e) { error_log('[BotService] processTermsResponse – update user accepted: ' . $e->getMessage()); } // Continuar con el flujo normal (welcome si es nuevo) if ($this->isNewUser($user['id'])) { $this->sendWelcomeMessage($phoneNumber); } else { // Ya había interactuado antes → solo confirmar y mostrar menú $this->showMainMenu($phoneNumber); } } else { // Rechazó — enviar mensaje de rechazo y dejar terms_pending=1 para que pueda aceptar después // Prioridad: system_config (lo que edita el usuario en "Configuración del Sistema") $rejMsg = getConfigFromDB('terms_rejected_message', ''); if (!$rejMsg && !empty($terms['mensaje_rechazo'])) { $rejMsg = $terms['mensaje_rechazo']; } if (!$rejMsg) { $rejMsg = 'Lamentablemente no podemos proceder sin tu aceptación. Cuando estés listo, responde *Acepto*.'; } $this->whatsappService->sendTextMessage($phoneNumber, $rejMsg); // Mantener terms_pending = 1 para que la próxima respuesta sea evaluada } error_log("[BotService] processTermsResponse – user {$user['id']} {$estado} terms v{$terms['version']}"); return true; } // ========================================================================= // FIN TÉRMINOS Y CONDICIONES // ========================================================================= /** * Verificar si es un nuevo usuario * Ahora: reenvía el welcome si no se envió o si el último welcome fue hace >= 6 horas */ 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ó recientemente $user = $this->db->fetch("SELECT welcome_sent_at FROM users WHERE id = :id", ['id' => $userId]); $welcomeSentAt = !empty($user['welcome_sent_at']) ? strtotime($user['welcome_sent_at']) : null; // Si nunca se envió el welcome, consideramos nuevo if (!$welcomeSentAt) return true; // Si el welcome fue hace 6 horas o más, consideramos que debe reenviarse $sixHours = 6 * 3600; if ((time() - $welcomeSentAt) >= $sixHours) return true; // En otros casos, no es nuevo return false; } /** * 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) {} // Prioritario: si estamos esperando documentos para esta conversación, manejarlo aquí try { $stateSvcTemp = new ConversationStateService(); $await = $stateSvcTemp->getStateData($phoneNumber, 'awaiting_documents'); } catch (Exception $e) { $await = null; } if (!empty($await)) { // Si llega multimedia (no text), marcar recibido y enviar ACK if ($messageType !== 'text') { try { $stateSvcTemp->updateStateData($phoneNumber, ['awaiting_documents' => ['since' => $await['since'], 'received' => true]]); } catch (Exception $e) { // ignore } try { $this->whatsappService->sendTextMessage($phoneNumber, "✅ Documento recibido. Cuando haya terminado, escriba *ENVIADA*."); } catch (Exception $e) { // ignore } return; // no procesar más } // Si es texto y dice enviada => transferir a asesor if (preg_match('/\b(enviad[oa]s?)\b/iu', $messageText)) { try { $this->requestAdvisor($phoneNumber, 3); // limpiar estado awaiting_documents $stateSvcTemp->updateStateData($phoneNumber, ['awaiting_documents' => null]); // registrar sistema para trazabilidad $sys = [ 'system' => 'user_documents_sent', 'type' => 'user_confirmed_documents', 'text' => 'Usuario confirmó envío de documentos con la palabra ENVIADA.' ]; $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 process ENVIADA: ' . $e->getMessage()); } return; } // Permitir navegación explícita (MENU/ATRÁS/números) y en los demás casos evitar respuestas automáticas $cmd = mb_strtolower(trim($messageText)); $backCommands = ['atras', 'atrás', 'volver', 'back']; if (in_array($cmd, ['menu','menú','inicio'], true)) { $this->showMainMenu($phoneNumber); return; } if (in_array($cmd, $backCommands, true)) { 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; } 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; } } 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)) { 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í y no es navegación, enviamos un aviso simple para que escriba ENVIADA try { $this->whatsappService->sendTextMessage($phoneNumber, "Si ya enviaste los documentos, escriba *ENVIADA* cuando hayas terminado. Si necesita volver al menú, escriba *MENU*. "); } catch (Exception $e) { // ignore } return; } // 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 (procesando saltos de línea) $message = $this->processLineBreaks($option['action_value']); $this->whatsappService->sendTextMessage($phoneNumber, $message); $this->exitMenu($phoneNumber); break; case 'api_call': // Llamar API externa (implementar según necesidad) $this->processApiCall($phoneNumber, $option['action_value']); break; case 'request_documents': // Solicitar al usuario que envíe documentación y activar estado awaiting_documents $message = $option['action_value'] ?: "Por favor, envíe los documentos o fotos requeridas."; $message = $this->processLineBreaks($message); $this->whatsappService->sendTextMessage($phoneNumber, $message); try { $stateSvc = new ConversationStateService(); $stateSvc->updateStateData($phoneNumber, ['awaiting_documents' => ['since' => date('c'), 'received' => false]]); $this->whatsappService->sendTextMessage($phoneNumber, "Cuando hayas enviado los documentos, escribe *ENVIADA* para que un asesor lo revise."); $sys = [ 'system' => 'awaiting_documents_started', 'type' => 'awaiting_documents', 'text' => 'Se solicitó documentación y se espera confirmación ENVIADA.' ]; $this->db->insert('conversations', [ 'user_id' => $this->db->fetch('SELECT id FROM users WHERE phone_number = :phone', ['phone' => $phoneNumber])['id'] ?? null, 'direction' => 'outgoing', 'message_type' => 'text', 'content' => json_encode($sys), 'status' => 'sent', 'created_at' => date('Y-m-d H:i:s') ]); } catch (Exception $e) { error_log('[BotService] request_documents failed: ' . $e->getMessage()); } $this->exitMenu($phoneNumber); break; case 'end': // Finalizar conversación (comportamiento por defecto) $message = $option['action_value'] ?: "Gracias por usar nuestro servicio. ¡Hasta pronto!"; $message = $this->processLineBreaks($message); $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 texto para respetar saltos de línea * Convierte \n escapados a saltos reales */ private function processLineBreaks($text) { if (empty($text)) return $text; // Convertir \n escapados (cadena literal) a saltos de línea reales $text = str_replace('\\n', "\n", $text); $text = str_replace('\n', "\n", $text); return $text; } /** * 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) { $message = $this->processLineBreaks($autoResponse['response_text']); $this->whatsappService->sendTextMessage($phoneNumber, $message); // 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, 'bot_enabled' => 0], '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 // Marcar solicitud de asesor y pausar bot temporalmente $this->db->update('users', ['advisor_requested' => 1, 'bot_paused_until' => $pausedUntil, 'bot_enabled' => 0], 'phone_number = :phone', ['phone' => $phoneNumber]); $this->whatsappService->sendTextMessage($phoneNumber, "🔔 Le hemos transferido a un asesor; le responderemos en breve."); // Enviar automáticamente el menú de horario_asesor para que el cliente sepa cuándo esperar try { $horarioMenu = $this->db->fetch( "SELECT id FROM menus WHERE name = :name AND is_active = 1", ['name' => 'horario_asesor'] ); if ($horarioMenu && !empty($horarioMenu['id'])) { $this->showMenu($phoneNumber, $horarioMenu['id']); error_log("[BotService] Sent horario_asesor menu to {$phoneNumber} after advisor request"); } else { error_log("[BotService] horario_asesor menu not found when requesting advisor for {$phoneNumber}"); } } catch (Exception $e) { error_log('Failed to send horario_asesor menu on advisor request: ' . $e->getMessage()); } 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, $notify = true) { try { // Clear hold/advisor flags and also ensure in_service is cleared for consistency // Clear hold and reactivate bot $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, 'bot_enabled' => 1], 'phone_number = :phone', ['phone' => $phoneNumber]); // Send notification only when requested (avoid duplicate messages after finish) if ($notify) { try { $this->whatsappService->sendTextMessage($phoneNumber, "🔔 Su conversación ha sido retomada por el equipo. Puede usar *MENU* para continuar."); } catch (Exception $e) { // ignore send errors } } if (function_exists('writeLog')) writeLog('INFO', "Advisor released hold for $phoneNumber notify=" . ($notify ? '1' : '0')); } catch (Exception $e) { error_log('releaseHold failed: ' . $e->getMessage()); } } /** * Si ha pasado 6 horas desde la última actividad, reinicia el estado de la conversación para que * el bot vuelva a enviar el mensaje de bienvenida y el usuario empiece /** * 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'); // Marcar como en servicio y desactivar el bot temporalmente (solo mientras el operador atiende) $update = ['in_service' => 1, 'in_service_at' => $now, 'advisor_requested' => 0, 'bot_enabled' => 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' // Limpiar in_service y reactivar el bot $this->db->update('users', ['in_service' => 0, 'in_service_by' => null, 'in_service_at' => null, 'advisor_requested' => 0, 'bot_enabled' => 1], '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 { // Release hold silently to avoid duplicate notification after finishing attend $this->releaseHold($phoneNumber, false); } 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; } } ?>