From 931b282f5b03a05485149e4a8ce8ac7d2c5bea67 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 20 Dec 2025 03:27:31 +0300 Subject: [PATCH] Enhance button handling in MenuLayoutService to improve connect button identification and URL management --- app/services/menu_layout/service.py | 107 ++++++++++++++-- tests/services/test_menu_layout_service.py | 139 +++++++++++++++++++++ 2 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 tests/services/test_menu_layout_service.py diff --git a/app/services/menu_layout/service.py b/app/services/menu_layout/service.py index 7600ab2a..601443c8 100644 --- a/app/services/menu_layout/service.py +++ b/app/services/menu_layout/service.py @@ -270,10 +270,44 @@ class MenuLayoutService: config = config.copy() buttons = config.get("buttons", {}) + # Улучшенное определение кнопки connect для разных форматов ID + actual_button_id = button_id if button_id not in buttons: - raise KeyError(f"Button '{button_id}' not found") + # Пробуем найти кнопку connect по разным форматам + if "connect" in button_id.lower(): + # Проверяем разные варианты: connect, callback:connect и т.д. + for key in buttons.keys(): + if key == "connect" or buttons[key].get("builtin_id") == "connect": + actual_button_id = key + logger.info( + f"🔗 Найдена кнопка connect по ID '{button_id}' -> '{actual_button_id}'" + ) + break + else: + # Если не нашли, пробуем найти по builtin_id + for key, button in buttons.items(): + if button.get("builtin_id") == "connect" or "connect" in str(button.get("builtin_id", "")).lower(): + actual_button_id = key + logger.info( + f"🔗 Найдена кнопка connect по builtin_id '{button_id}' -> '{actual_button_id}'" + ) + break + else: + raise KeyError(f"Button '{button_id}' not found") - button = buttons[button_id].copy() + if actual_button_id not in buttons: + raise KeyError(f"Button '{actual_button_id}' not found") + + button = buttons[actual_button_id].copy() + + # Логирование для отладки + if "connect" in actual_button_id.lower() or button.get("builtin_id") == "connect": + logger.info( + f"🔗 Обновление кнопки connect (ID: {actual_button_id}): " + f"open_mode={updates.get('open_mode')}, " + f"action={updates.get('action')}, " + f"webapp_url={updates.get('webapp_url')}" + ) # Применяем обновления if "text" in updates and updates["text"] is not None: @@ -296,12 +330,16 @@ class MenuLayoutService: # Для URL/MiniApp/callback кнопок можно менять action if button.get("type") in ("url", "mini_app", "callback"): button["action"] = updates["action"] + # Для builtin кнопок можно менять action, если open_mode == "direct" + # Это позволяет указать URL Mini App в поле action для кнопки connect + elif button.get("type") == "builtin" and updates.get("open_mode") == "direct": + button["action"] = updates["action"] if "open_mode" in updates and updates["open_mode"] is not None: button["open_mode"] = updates["open_mode"] if "webapp_url" in updates: button["webapp_url"] = updates["webapp_url"] - buttons[button_id] = button + buttons[actual_button_id] = button config["buttons"] = buttons await cls.save_config(db, config) @@ -905,11 +943,28 @@ class MenuLayoutService: ) -> Optional[InlineKeyboardButton]: """Построить кнопку из конфигурации.""" button_type = button_config.get("type", "builtin") + button_id = button_config.get("builtin_id") or button_config.get("id", "") text_config = button_config.get("text", {}) action = button_config.get("action", "") open_mode = button_config.get("open_mode", "callback") webapp_url = button_config.get("webapp_url") icon = button_config.get("icon", "") + + # Логирование для отладки кнопки connect + is_connect_button = ( + button_id == "connect" or + "connect" in str(button_id).lower() or + action == "subscription_connect" or + "connect" in str(action).lower() + ) + + if is_connect_button: + logger.info( + f"🔗 Построение кнопки connect: " + f"button_id={button_id}, type={button_type}, " + f"open_mode={open_mode}, action={action}, " + f"webapp_url={webapp_url}" + ) # Получаем текст text = cls._get_localized_text(text_config, context.language) @@ -936,13 +991,51 @@ class MenuLayoutService: return InlineKeyboardButton(text=text, callback_data=action) else: # builtin - проверяем open_mode - if open_mode == "direct" and webapp_url: + if open_mode == "direct": # Прямое открытие Mini App через WebAppInfo - return InlineKeyboardButton( - text=text, web_app=types.WebAppInfo(url=webapp_url) - ) + # Используем webapp_url, если указан, иначе action (если это URL) + url = webapp_url or action + + # Для кнопки connect: если URL не указан или это callback_data, + # пытаемся получить URL из подписки пользователя + if is_connect_button and (not url or not (url.startswith("http://") or url.startswith("https://"))): + if context.subscription: + from app.utils.subscription_utils import get_display_subscription_link + subscription_url = get_display_subscription_link(context.subscription) + if subscription_url: + url = subscription_url + logger.info( + f"🔗 Кнопка connect: получен URL из подписки: {url[:50]}..." + ) + # Если все еще нет URL, пробуем использовать настройку MINIAPP_CUSTOM_URL + if not url or not (url.startswith("http://") or url.startswith("https://")): + if settings.MINIAPP_CUSTOM_URL: + url = settings.MINIAPP_CUSTOM_URL + logger.info( + f"🔗 Кнопка connect: использован MINIAPP_CUSTOM_URL: {url[:50]}..." + ) + + # Проверяем, что это действительно URL + if url and (url.startswith("http://") or url.startswith("https://")): + logger.info( + f"🔗 Кнопка connect: open_mode=direct, используем URL: {url[:50]}..." + ) + return InlineKeyboardButton( + text=text, web_app=types.WebAppInfo(url=url) + ) + else: + logger.warning( + f"🔗 Кнопка connect: open_mode=direct, но URL не найден. " + f"webapp_url={webapp_url}, action={action}, " + f"subscription_url={'есть' if context.subscription else 'нет'}" + ) + # Fallback на callback_data + return InlineKeyboardButton(text=text, callback_data=action) else: # Стандартный callback_data + logger.debug( + f"Кнопка connect: open_mode={open_mode}, используем callback_data: {action}" + ) return InlineKeyboardButton(text=text, callback_data=action) # --- Построение клавиатуры --- diff --git a/tests/services/test_menu_layout_service.py b/tests/services/test_menu_layout_service.py new file mode 100644 index 00000000..94d877f4 --- /dev/null +++ b/tests/services/test_menu_layout_service.py @@ -0,0 +1,139 @@ +"""Тесты для MenuLayoutService.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from aiogram.types import InlineKeyboardButton + +from app.services.menu_layout.service import MenuLayoutService +from app.services.menu_layout.context import MenuContext + + +@pytest.mark.anyio +async def test_build_button_connect_direct_mode_with_url(): + """Тест: кнопка connect с open_mode=direct и валидным URL должна создавать WebAppInfo.""" + button_config = { + "type": "builtin", + "builtin_id": "connect", + "text": {"ru": "🔗 Подключиться"}, + "action": "subscription_connect", + "open_mode": "direct", + "webapp_url": "https://example.com/miniapp", + } + + context = MenuContext( + language="ru", + has_active_subscription=True, + subscription_is_active=True, + ) + + texts = MagicMock() + texts.t = lambda key, default: default + + button = MenuLayoutService._build_button(button_config, context, texts) + + assert button is not None + assert isinstance(button, InlineKeyboardButton) + assert button.web_app is not None + assert button.web_app.url == "https://example.com/miniapp" + assert button.callback_data is None + + +@pytest.mark.anyio +async def test_build_button_connect_direct_mode_with_subscription_url(): + """Тест: кнопка connect с open_mode=direct должна получать URL из подписки.""" + button_config = { + "type": "builtin", + "builtin_id": "connect", + "text": {"ru": "🔗 Подключиться"}, + "action": "subscription_connect", + "open_mode": "direct", + "webapp_url": None, + } + + # Мокаем подписку с URL + mock_subscription = MagicMock() + mock_subscription.subscription_url = "https://subscription.example.com/link" + mock_subscription.subscription_crypto_link = None + + context = MenuContext( + language="ru", + has_active_subscription=True, + subscription_is_active=True, + subscription=mock_subscription, + ) + + texts = MagicMock() + texts.t = lambda key, default: default + + with patch('app.utils.subscription_utils.get_display_subscription_link') as mock_get_link: + mock_get_link.return_value = "https://subscription.example.com/link" + + button = MenuLayoutService._build_button(button_config, context, texts) + + assert button is not None + assert isinstance(button, InlineKeyboardButton) + assert button.web_app is not None + assert button.web_app.url == "https://subscription.example.com/link" + + +@pytest.mark.anyio +async def test_build_button_connect_callback_mode(): + """Тест: кнопка connect с open_mode=callback должна создавать callback кнопку.""" + button_config = { + "type": "builtin", + "builtin_id": "connect", + "text": {"ru": "🔗 Подключиться"}, + "action": "subscription_connect", + "open_mode": "callback", + "webapp_url": None, + } + + context = MenuContext( + language="ru", + has_active_subscription=True, + subscription_is_active=True, + ) + + texts = MagicMock() + texts.t = lambda key, default: default + + button = MenuLayoutService._build_button(button_config, context, texts) + + assert button is not None + assert isinstance(button, InlineKeyboardButton) + assert button.callback_data == "subscription_connect" + assert button.web_app is None + + +@pytest.mark.anyio +async def test_build_button_connect_direct_mode_fallback_to_callback(): + """Тест: кнопка connect с open_mode=direct без URL должна fallback на callback.""" + button_config = { + "type": "builtin", + "builtin_id": "connect", + "text": {"ru": "🔗 Подключиться"}, + "action": "subscription_connect", + "open_mode": "direct", + "webapp_url": None, + } + + context = MenuContext( + language="ru", + has_active_subscription=True, + subscription_is_active=True, + subscription=None, # Нет подписки + ) + + texts = MagicMock() + texts.t = lambda key, default: default + + with patch('app.services.menu_layout.service.settings') as mock_settings: + mock_settings.MINIAPP_CUSTOM_URL = None + + button = MenuLayoutService._build_button(button_config, context, texts) + + assert button is not None + assert isinstance(button, InlineKeyboardButton) + # Должен fallback на callback_data, так как URL не найден + assert button.callback_data == "subscription_connect" +