"""Middleware для автоматического логирования кликов по кнопкам.""" import asyncio import logging from typing import Callable, Dict, Any, Awaitable, Set from aiogram import BaseMiddleware from aiogram.types import CallbackQuery, TelegramObject from app.config import settings from app.database.database import AsyncSessionLocal logger = logging.getLogger(__name__) # Известные builtin callback_data из меню BUILTIN_CALLBACKS: Set[str] = { # Основные кнопки меню "subscription_connect", "subscription_happ_download", "menu_subscription", "buy_traffic", "menu_balance", "menu_trial", "menu_buy", "simple_subscription_purchase", "return_to_saved_cart", "menu_promocode", "menu_referrals", "contests_menu", "menu_support", "menu_info", "menu_language", "admin_panel", "moderator_panel", # Навигация "back_to_menu", "menu_faq", "menu_info_promo_groups", "menu_privacy_policy", "menu_public_offer", "menu_rules", "menu_server_status", # Баланс "balance_history", "balance_topup", # Подписка "subscription_extend", "subscription_autopay", "subscription_settings", "open_subscription_link", "subscription_add_countries", "subscription_reset_traffic", "subscription_switch_traffic", "subscription_change_devices", "subscription_manage_devices", "subscription_upgrade", # Устройства "device_guide_ios", "device_guide_android", "device_guide_windows", "device_guide_mac", "device_guide_tv", "device_guide_appletv", # Happ "happ_download_ios", "happ_download_android", "happ_download_macos", "happ_download_windows", # Рефералы "referral_create_invite", "referral_show_qr", "referral_list", "referral_analytics", # Поддержка "create_ticket", "my_tickets", # Триал "trial_activate", # Покупка "clear_saved_cart", "subscription_confirm", "subscription_cancel", } class ButtonStatsMiddleware(BaseMiddleware): """Middleware для автоматического логирования статистики кликов по кнопкам.""" async def __call__( self, handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], event: TelegramObject, data: Dict[str, Any] ) -> Any: """Перехватывает CallbackQuery и логирует клики по кнопкам.""" # Обрабатываем только CallbackQuery if not isinstance(event, CallbackQuery): return await handler(event, data) # Пропускаем, если статистика отключена if not settings.MENU_LAYOUT_ENABLED: return await handler(event, data) # Логируем клик асинхронно, не блокируя обработку try: # Получаем callback_data callback_data = event.data if not callback_data: return await handler(event, data) # Получаем user_id user_id = event.from_user.id if event.from_user else None # Определяем тип кнопки по callback_data button_type = self._determine_button_type(callback_data) # Получаем текст кнопки, если возможно button_text = None if event.message and hasattr(event.message, 'reply_markup'): button_text = self._extract_button_text(event.message.reply_markup, callback_data) # Логируем в фоне, не блокируя обработку asyncio.create_task( self._log_button_click_async( button_id=callback_data, user_id=user_id, callback_data=callback_data, button_type=button_type, button_text=button_text ) ) except Exception as e: # Не прерываем обработку при ошибке логирования logger.error(f"Ошибка логирования клика по кнопке: {e}", exc_info=True) # Продолжаем обработку return await handler(event, data) def _determine_button_type(self, callback_data: str) -> str: """Определяет тип кнопки по callback_data. Примечание: URL и MiniApp кнопки не имеют callback_data, поэтому они не отслеживаются через этот middleware. Для их отслеживания нужен отдельный механизм на стороне клиента. """ # Проверяем по известному списку builtin кнопок if callback_data in BUILTIN_CALLBACKS: return "builtin" # Дополнительная проверка по префиксам для динамических callback_data builtin_prefixes = ( "menu_", "admin_", "subscription_", "balance_", "referral_", "device_guide_", "happ_download_", ) if callback_data.startswith(builtin_prefixes): return "builtin" # Всё остальное - кастомные callback кнопки return "callback" def _extract_button_text(self, reply_markup, callback_data: str) -> str: """Извлекает текст кнопки из клавиатуры.""" try: if not reply_markup or not hasattr(reply_markup, 'inline_keyboard'): return None for row in reply_markup.inline_keyboard: for button in row: if hasattr(button, 'callback_data') and button.callback_data == callback_data: if hasattr(button, 'text'): return button.text except Exception: pass return None async def _log_button_click_async( self, button_id: str, user_id: int = None, callback_data: str = None, button_type: str = None, button_text: str = None ): """Асинхронно логирует клик по кнопке.""" try: async with AsyncSessionLocal() as db: try: from app.services.menu_layout_service import MenuLayoutService await MenuLayoutService.log_button_click( db, button_id=button_id, user_id=user_id, callback_data=callback_data, button_type=button_type, button_text=button_text ) except Exception as e: logger.debug(f"Ошибка записи клика в БД {button_id}: {e}") except Exception as e: logger.debug(f"Ошибка создания сессии БД для логирования клика: {e}")