diff --git a/app/cabinet/routes/admin_apps.py b/app/cabinet/routes/admin_apps.py index b3b1a7ec..ec6bbf48 100644 --- a/app/cabinet/routes/admin_apps.py +++ b/app/cabinet/routes/admin_apps.py @@ -505,6 +505,10 @@ async def set_remnawave_config_uuid( try: await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid_value) await db.commit() + + from app.handlers.subscription.common import invalidate_app_config_cache + + invalidate_app_config_cache() logger.info('Admin updated CABINET_REMNA_SUB_CONFIG to', admin_id=admin.id, uuid_value=uuid_value) except Exception as e: logger.error('Error saving RemnaWave config UUID', error=e) diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py index 65610535..27883c4c 100644 --- a/app/handlers/admin/bot_configuration.py +++ b/app/handlers/admin/bot_configuration.py @@ -2690,6 +2690,118 @@ async def apply_setting_choice( await callback.answer('Значение обновлено') +# ── Remnawave App Config Selector ── + + +@admin_required +@error_handler +async def show_remna_config_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession, **kwargs): + """Show available Remnawave subscription page configs for selection.""" + current_uuid = bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG') + + try: + service = RemnaWaveService() + async with service.get_api_client() as api: + configs = await api.get_subscription_page_configs() + except Exception as e: + await callback.answer(f'Ошибка загрузки конфигов: {e}', show_alert=True) + return + + keyboard: list[list[types.InlineKeyboardButton]] = [] + + if not configs: + text = ( + '📱 Конфиг приложений (Remnawave)\n\n' + 'В Remnawave не найдено конфигураций страниц подписки.\n\n' + 'Создайте конфигурацию в панели Remnawave, затем вернитесь сюда для выбора.' + ) + else: + text = '📱 Конфиг приложений (Remnawave)\n\n' + if current_uuid: + current_name = next((c.name for c in configs if c.uuid == current_uuid), None) + if current_name: + text += f'✅ Текущий: {html.escape(current_name)}\n\n' + else: + text += f'⚠️ Текущий UUID не найден: {current_uuid}\n\n' + else: + text += 'ℹ️ Конфиг не выбран (используется app-config.json)\n\n' + + text += 'Выберите конфигурацию для гайд-режима:' + + for config in configs: + prefix = '✅ ' if config.uuid == current_uuid else '' + keyboard.append( + [ + types.InlineKeyboardButton( + text=f'{prefix}{config.name}', + callback_data=f'admin_remna_select_{config.uuid}', + ) + ] + ) + + if current_uuid: + keyboard.append( + [ + types.InlineKeyboardButton( + text='🗑 Сбросить (использовать app-config.json)', + callback_data='admin_remna_clear', + ) + ] + ) + + keyboard.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_submenu_settings')]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode='HTML', + ) + await callback.answer() + + +@admin_required +@error_handler +async def select_remna_config(callback: types.CallbackQuery, db_user: User, db: AsyncSession, **kwargs): + """Select a Remnawave subscription page config.""" + uuid = callback.data.replace('admin_remna_select_', '') + + try: + await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid) + await db.commit() + except Exception as e: + await callback.answer(f'Ошибка сохранения: {e}', show_alert=True) + return + + # Invalidate app config cache + from app.handlers.subscription.common import invalidate_app_config_cache + + invalidate_app_config_cache() + + await callback.answer('✅ Конфиг выбран', show_alert=True) + + # Re-render the menu + await show_remna_config_menu(callback, db_user=db_user, db=db) + + +@admin_required +@error_handler +async def clear_remna_config(callback: types.CallbackQuery, db_user: User, db: AsyncSession, **kwargs): + """Clear the Remnawave config, reverting to local app-config.json.""" + try: + await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', '') + await db.commit() + except Exception as e: + await callback.answer(f'Ошибка сброса: {e}', show_alert=True) + return + + from app.handlers.subscription.common import invalidate_app_config_cache + + invalidate_app_config_cache() + + await callback.answer('✅ Конфиг сброшен', show_alert=True) + await show_remna_config_menu(callback, db_user=db_user, db=db) + + def register_handlers(dp: Dispatcher) -> None: dp.callback_query.register( show_bot_config_menu, @@ -2789,3 +2901,16 @@ def register_handlers(dp: Dispatcher) -> None: handle_import_message, BotConfigStates.waiting_for_import_file, ) + # Remnawave app config selector + dp.callback_query.register( + show_remna_config_menu, + F.data == 'admin_remna_config', + ) + dp.callback_query.register( + select_remna_config, + F.data.startswith('admin_remna_select_'), + ) + dp.callback_query.register( + clear_remna_config, + F.data == 'admin_remna_clear', + ) diff --git a/app/handlers/subscription/__init__.py b/app/handlers/subscription/__init__.py index 8d4b3637..72d95127 100644 --- a/app/handlers/subscription/__init__.py +++ b/app/handlers/subscription/__init__.py @@ -14,13 +14,19 @@ from .common import ( format_additional_section, format_traffic_display, get_apps_for_device, + get_apps_for_platform_async, get_confirm_switch_traffic_keyboard, get_device_name, get_localized_value, + get_platforms_list, get_reset_devices_confirm_keyboard, get_step_description, get_traffic_switch_keyboard, + invalidate_app_config_cache, load_app_config, + load_app_config_async, + normalize_app, + resolve_button_url, update_traffic_prices, validate_traffic_price, ) @@ -135,12 +141,14 @@ __all__ = [ 'format_additional_section', 'format_traffic_display', 'get_apps_for_device', + 'get_apps_for_platform_async', 'get_confirm_switch_traffic_keyboard', 'get_countries_price_by_uuids_fallback', 'get_current_devices_count', 'get_current_devices_detailed', 'get_device_name', 'get_localized_value', + 'get_platforms_list', 'get_reset_devices_confirm_keyboard', 'get_servers_display_names', 'get_step_description', @@ -176,9 +184,13 @@ __all__ = [ 'handle_subscription_config_back', 'handle_subscription_settings', 'handle_switch_traffic', + 'invalidate_app_config_cache', 'load_app_config', + 'load_app_config_async', + 'normalize_app', 'refresh_traffic_config', 'register_handlers', + 'resolve_button_url', 'resume_subscription_checkout', 'return_to_saved_cart', 'save_cart_and_redirect_to_topup', diff --git a/app/handlers/subscription/common.py b/app/handlers/subscription/common.py index 7dd99d68..a98870bd 100644 --- a/app/handlers/subscription/common.py +++ b/app/handlers/subscription/common.py @@ -1,5 +1,7 @@ +import asyncio import base64 import json +import time from datetime import datetime from typing import Any from urllib.parse import quote @@ -23,6 +25,11 @@ logger = structlog.get_logger(__name__) TRAFFIC_PRICES = get_traffic_prices() +# ── App config cache ── +_app_config_cache: dict[str, Any] = {} +_app_config_cache_ts: float = 0.0 +_app_config_lock = asyncio.Lock() + class _SafeFormatDict(dict): def __missing__(self, key: str) -> str: # pragma: no cover - defensive fallback @@ -294,6 +301,7 @@ def get_device_name(device_type: str, language: str = 'ru') -> str: 'android': 'Android', 'windows': 'Windows', 'mac': 'macOS', + 'linux': 'Linux', 'tv': 'Android TV', 'appletv': 'Apple TV', 'apple_tv': 'Apple TV', @@ -302,6 +310,331 @@ def get_device_name(device_type: str, language: str = 'ru') -> str: return names.get(device_type, device_type) +# ── Remnawave async config loader ── + +_PLATFORM_DISPLAY = { + 'ios': {'name': 'iPhone/iPad', 'emoji': '📱'}, + 'android': {'name': 'Android', 'emoji': '🤖'}, + 'windows': {'name': 'Windows', 'emoji': '💻'}, + 'macos': {'name': 'macOS', 'emoji': '🎯'}, + 'linux': {'name': 'Linux', 'emoji': '🐧'}, + 'androidTV': {'name': 'Android TV', 'emoji': '📺'}, + 'appleTV': {'name': 'Apple TV', 'emoji': '📺'}, +} + +# Map legacy device_type keys to Remnawave platform keys +_DEVICE_TO_PLATFORM = { + 'ios': 'ios', + 'android': 'android', + 'windows': 'windows', + 'mac': 'macos', + 'linux': 'linux', + 'tv': 'androidTV', + 'appletv': 'appleTV', + 'apple_tv': 'appleTV', +} + +# Reverse: Remnawave platform key → legacy callback device_type +_PLATFORM_TO_DEVICE = { + 'ios': 'ios', + 'android': 'android', + 'windows': 'windows', + 'macos': 'mac', + 'linux': 'linux', + 'androidTV': 'tv', + 'appleTV': 'appletv', +} + + +def _get_remnawave_config_uuid() -> str | None: + try: + from app.services.system_settings_service import bot_configuration_service + + return bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG') + except Exception: + return getattr(settings, 'CABINET_REMNA_SUB_CONFIG', None) + + +async def load_app_config_async() -> dict[str, Any]: + """Load app config from Remnawave API (if configured) or local file, with TTL cache.""" + global _app_config_cache, _app_config_cache_ts + + ttl = settings.APP_CONFIG_CACHE_TTL + if _app_config_cache and (time.monotonic() - _app_config_cache_ts) < ttl: + return _app_config_cache + + async with _app_config_lock: + # Double-check after acquiring lock + if _app_config_cache and (time.monotonic() - _app_config_cache_ts) < ttl: + return _app_config_cache + + remnawave_uuid = _get_remnawave_config_uuid() + + if remnawave_uuid: + try: + from app.services.remnawave_service import RemnaWaveService + + service = RemnaWaveService() + async with service.get_api_client() as api: + config = await api.get_subscription_page_config(remnawave_uuid) + if config and config.config: + raw = dict(config.config) + raw['_isRemnawave'] = True + _app_config_cache = raw + _app_config_cache_ts = time.monotonic() + logger.debug('Loaded app config from Remnawave', remnawave_uuid=remnawave_uuid) + return raw + except Exception as e: + logger.warning('Failed to load Remnawave config, falling back to file', error=e) + + fallback = load_app_config() + _app_config_cache = fallback + _app_config_cache_ts = time.monotonic() + return fallback + + +def invalidate_app_config_cache() -> None: + """Clear the cached app config so next call re-fetches from Remnawave.""" + global _app_config_cache, _app_config_cache_ts + _app_config_cache = {} + _app_config_cache_ts = 0.0 + + +async def get_apps_for_platform_async(device_type: str, language: str = 'ru') -> list[dict[str, Any]]: + """Get apps for a device type, using async Remnawave config if available.""" + config = await load_app_config_async() + is_remnawave = config.get('_isRemnawave', False) + platforms = config.get('platforms', {}) + + if not isinstance(platforms, dict): + return [] + + if is_remnawave: + platform_key = _DEVICE_TO_PLATFORM.get(device_type, device_type) + platform_data = platforms.get(platform_key) + if isinstance(platform_data, dict): + apps = platform_data.get('apps', []) + return [normalize_app(app, is_remnawave=True) for app in apps if isinstance(app, dict)] + return [] + + # Legacy format — uses different keys for some platforms + legacy_mapping = { + 'ios': 'ios', + 'android': 'android', + 'windows': 'windows', + 'mac': 'macos', + 'macos': 'macos', + 'linux': 'linux', + 'tv': 'androidTV', + 'androidTV': 'androidTV', + 'appletv': 'appleTV', + 'appleTV': 'appleTV', + 'apple_tv': 'appleTV', + } + config_key = legacy_mapping.get(device_type, device_type) + apps = platforms.get(config_key, []) + if isinstance(apps, list): + return [normalize_app(app, is_remnawave=False) for app in apps if isinstance(app, dict)] + return [] + + +def normalize_app(app: dict[str, Any], *, is_remnawave: bool) -> dict[str, Any]: + """Normalize app dict to a unified format with blocks. + + For legacy apps: converts installationStep/addSubscriptionStep/connectAndUseStep into blocks. + For Remnawave apps: already has blocks, just ensure consistent fields. + """ + if is_remnawave: + return { + 'id': app.get('id', app.get('name', 'unknown')), + 'name': app.get('name', ''), + 'isFeatured': app.get('featured', app.get('isFeatured', False)), + 'urlScheme': app.get('urlScheme', ''), + 'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False), + 'blocks': app.get('blocks', []), + '_raw': app, + } + + # Legacy format → convert steps to blocks + blocks: list[dict[str, Any]] = [] + + # Installation step → block with download buttons + install_step = app.get('installationStep') + if isinstance(install_step, dict): + install_block: dict[str, Any] = { + 'title': install_step.get('title', {'en': 'Installation', 'ru': 'Установка'}), + 'description': install_step.get('description', {}), + 'buttons': [], + } + for btn in install_step.get('buttons', []): + if isinstance(btn, dict): + install_block['buttons'].append( + { + 'type': 'externalLink', + 'text': btn.get('buttonText', {}), + 'url': btn.get('buttonLink', ''), + } + ) + blocks.append(install_block) + + # additionalBeforeAddSubscriptionStep + add_before = app.get('additionalBeforeAddSubscriptionStep') + if isinstance(add_before, dict): + before_block: dict[str, Any] = { + 'title': add_before.get('title', {}), + 'description': add_before.get('description', {}), + 'buttons': [], + } + for btn in add_before.get('buttons', []): + if isinstance(btn, dict): + before_block['buttons'].append( + { + 'type': 'externalLink', + 'text': btn.get('buttonText', {}), + 'url': btn.get('buttonLink', ''), + } + ) + blocks.append(before_block) + + # Add subscription step + add_step = app.get('addSubscriptionStep') + if isinstance(add_step, dict): + add_block: dict[str, Any] = { + 'title': add_step.get('title', {'en': 'Add subscription', 'ru': 'Добавление подписки'}), + 'description': add_step.get('description', {}), + 'buttons': [ + { + 'type': 'subscriptionLink', + 'text': {'en': 'Connect', 'ru': 'Подключиться'}, + 'url': '{{SUBSCRIPTION_LINK}}', + } + ], + } + blocks.append(add_block) + + # additionalAfterAddSubscriptionStep + add_after = app.get('additionalAfterAddSubscriptionStep') + if isinstance(add_after, dict): + after_block: dict[str, Any] = { + 'title': add_after.get('title', {}), + 'description': add_after.get('description', {}), + 'buttons': [], + } + for btn in add_after.get('buttons', []): + if isinstance(btn, dict): + after_block['buttons'].append( + { + 'type': 'externalLink', + 'text': btn.get('buttonText', {}), + 'url': btn.get('buttonLink', ''), + } + ) + blocks.append(after_block) + + # Connect and use step + connect_step = app.get('connectAndUseStep') + if isinstance(connect_step, dict): + connect_block: dict[str, Any] = { + 'title': connect_step.get('title', {'en': 'Connect & Use', 'ru': 'Подключение'}), + 'description': connect_step.get('description', {}), + 'buttons': [], + } + blocks.append(connect_block) + + return { + 'id': app.get('id', app.get('name', 'unknown')), + 'name': app.get('name', ''), + 'isFeatured': app.get('isFeatured', False), + 'urlScheme': app.get('urlScheme', ''), + 'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False), + 'blocks': blocks, + '_raw': app, + } + + +def get_platforms_list(config: dict[str, Any]) -> list[dict[str, Any]]: + """Extract available platforms from config for keyboard generation. + + Returns list of {key, displayName, icon_emoji, device_type} sorted by typical order. + """ + is_remnawave = config.get('_isRemnawave', False) + platforms = config.get('platforms', {}) + if not isinstance(platforms, dict): + return [] + + # Desired order + order = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'] + + result = [] + for pk in order: + if pk not in platforms: + continue + pd = platforms[pk] + + # Check platform has apps + if is_remnawave: + if not isinstance(pd, dict) or not pd.get('apps'): + continue + elif not isinstance(pd, list) or not pd: + continue + + display = _PLATFORM_DISPLAY.get(pk, {'name': pk, 'emoji': '📱'}) + + # Get displayName from Remnawave or fallback + if is_remnawave and isinstance(pd, dict) and 'displayName' in pd: + display_name_data = pd['displayName'] + else: + display_name_data = display['name'] + + result.append( + { + 'key': pk, + 'displayName': display_name_data, + 'icon_emoji': display['emoji'], + 'device_type': _PLATFORM_TO_DEVICE.get(pk, pk), + } + ) + + # Also include any platforms in config not in our order list + for pk, pd in platforms.items(): + if pk in order: + continue + if is_remnawave: + if not isinstance(pd, dict) or not pd.get('apps'): + continue + elif not isinstance(pd, list) or not pd: + continue + + display = _PLATFORM_DISPLAY.get(pk, {'name': pk, 'emoji': '📱'}) + result.append( + { + 'key': pk, + 'displayName': display.get('name', pk), + 'icon_emoji': display.get('emoji', '📱'), + 'device_type': _PLATFORM_TO_DEVICE.get(pk, pk), + } + ) + + return result + + +def resolve_button_url( + url: str, + subscription_url: str | None, + crypto_link: str | None = None, +) -> str: + """Resolve template variables in button URLs (port of cabinet's _resolve_button_url).""" + if not url: + return url + result = url + if subscription_url: + result = result.replace('{{SUBSCRIPTION_LINK}}', subscription_url) + if crypto_link: + result = result.replace('{{HAPP_CRYPT3_LINK}}', crypto_link) + result = result.replace('{{HAPP_CRYPT4_LINK}}', crypto_link) + return result + + def create_deep_link(app: dict[str, Any], subscription_url: str) -> str | None: if not subscription_url: return None diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index f7c0cd99..a598ff8f 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -38,7 +38,9 @@ from .common import ( _get_period_hint_from_subscription, format_additional_section, get_apps_for_device, + get_apps_for_platform_async, get_device_name, + get_localized_value, get_step_description, logger, ) @@ -1271,7 +1273,19 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db: ) return - apps = get_apps_for_device(device_type, db_user.language) + # Try async Remnawave config first, fall back to legacy sync + apps = await get_apps_for_platform_async(device_type, db_user.language) + is_blocks_format = bool(apps and apps[0].get('blocks')) + + # If async returned empty, try legacy + if not apps: + apps_raw = get_apps_for_device(device_type, db_user.language) + if apps_raw: + from .common import normalize_app + + apps = [normalize_app(a, is_remnawave=False) for a in apps_raw] + is_blocks_format = True + hide_subscription_link = settings.should_hide_subscription_link() if not apps: @@ -1307,20 +1321,6 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db: + f'\n{subscription_link}\n\n' ) - installation_description = get_step_description(featured_app, 'installationStep', db_user.language) - add_description = get_step_description(featured_app, 'addSubscriptionStep', db_user.language) - connect_description = get_step_description(featured_app, 'connectAndUseStep', db_user.language) - additional_before_text = format_additional_section( - featured_app.get('additionalBeforeAddSubscriptionStep'), - texts, - db_user.language, - ) - additional_after_text = format_additional_section( - featured_app.get('additionalAfterAddSubscriptionStep'), - texts, - db_user.language, - ) - guide_text = ( texts.t( 'SUBSCRIPTION_DEVICE_GUIDE_TITLE', @@ -1344,20 +1344,64 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db: 'Нажмите кнопку "Другие приложения" ниже, чтобы выбрать приложение.', ) - guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE', 'Шаг 1 - Установка:') - if installation_description: - guide_text += f'\n{installation_description}' + if is_blocks_format: + # Build guide text from blocks + step_num = 1 + for block in featured_app.get('blocks', []): + if not isinstance(block, dict): + continue + title = block.get('title', {}) + desc = block.get('description', {}) + title_text = get_localized_value(title, db_user.language) if isinstance(title, dict) else str(title or '') + desc_text = get_localized_value(desc, db_user.language) if isinstance(desc, dict) else str(desc or '') - if additional_before_text: - guide_text += f'\n\n{additional_before_text}' + if title_text or desc_text: + guide_text += f'\n\nШаг {step_num}' + if title_text: + guide_text += f' - {title_text}' + guide_text += ':' + if desc_text: + guide_text += f'\n{desc_text}' + step_num += 1 + else: + # Legacy steps + installation_description = get_step_description( + featured_app.get('_raw', featured_app), 'installationStep', db_user.language + ) + add_description = get_step_description( + featured_app.get('_raw', featured_app), 'addSubscriptionStep', db_user.language + ) + connect_description = get_step_description( + featured_app.get('_raw', featured_app), 'connectAndUseStep', db_user.language + ) + additional_before_text = format_additional_section( + featured_app.get('_raw', featured_app).get('additionalBeforeAddSubscriptionStep'), + texts, + db_user.language, + ) + additional_after_text = format_additional_section( + featured_app.get('_raw', featured_app).get('additionalAfterAddSubscriptionStep'), + texts, + db_user.language, + ) - guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_ADD_TITLE', 'Шаг 2 - Добавление подписки:') - if add_description: - guide_text += f'\n{add_description}' + guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE', 'Шаг 1 - Установка:') + if installation_description: + guide_text += f'\n{installation_description}' - guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE', 'Шаг 3 - Подключение:') - if connect_description: - guide_text += f'\n{connect_description}' + if additional_before_text: + guide_text += f'\n\n{additional_before_text}' + + guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_ADD_TITLE', 'Шаг 2 - Добавление подписки:') + if add_description: + guide_text += f'\n{add_description}' + + guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE', 'Шаг 3 - Подключение:') + if connect_description: + guide_text += f'\n{connect_description}' + + if additional_after_text: + guide_text += f'\n\n{additional_after_text}' guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_HOW_TO_TITLE', '💡 Как подключить:') guide_text += '\n' + '\n'.join( @@ -1381,17 +1425,18 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db: ] ) - if additional_after_text: - guide_text += f'\n\n{additional_after_text}' + # For keyboard: pass raw app if legacy, or normalized app with blocks + keyboard_app = featured_app.get('_raw', featured_app) if not is_blocks_format else featured_app await callback.message.edit_text( guide_text, reply_markup=get_connection_guide_keyboard( subscription_link, - featured_app, + keyboard_app, device_type, db_user.language, has_other_apps=bool(other_apps), + is_blocks_format=is_blocks_format, ), parse_mode='HTML', ) @@ -1402,7 +1447,14 @@ async def handle_app_selection(callback: types.CallbackQuery, db_user: User, db: device_type = callback.data.split('_')[2] texts = get_texts(db_user.language) - apps = get_apps_for_device(device_type, db_user.language) + apps = await get_apps_for_platform_async(device_type, db_user.language) + if not apps: + # Fallback to legacy + apps_raw = get_apps_for_device(device_type, db_user.language) + if apps_raw: + from .common import normalize_app + + apps = [normalize_app(a, is_remnawave=False) for a in apps_raw] if not apps: await callback.answer( @@ -1427,7 +1479,11 @@ async def handle_app_selection(callback: types.CallbackQuery, db_user: User, db: async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User, db: AsyncSession): - _, device_type, app_id = callback.data.split('_') + parts = callback.data.split('_', 2) + if len(parts) < 3: + await callback.answer('Invalid callback data', show_alert=True) + return + _, device_type, app_id = parts texts = get_texts(db_user.language) subscription = db_user.subscription @@ -1440,8 +1496,20 @@ async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User ) return - apps = get_apps_for_device(device_type, db_user.language) - app = next((a for a in apps if a['id'] == app_id), None) + # Try async config first + apps = await get_apps_for_platform_async(device_type, db_user.language) + is_blocks_format = bool(apps and apps[0].get('blocks')) + app = next((a for a in apps if a.get('id') == app_id), None) if apps else None + + # Fallback to legacy + if not app: + apps_raw = get_apps_for_device(device_type, db_user.language) + app_raw = next((a for a in apps_raw if a.get('id') == app_id), None) if apps_raw else None + if app_raw: + from .common import normalize_app + + app = normalize_app(app_raw, is_remnawave=False) + is_blocks_format = True if not app: await callback.answer( @@ -1468,20 +1536,6 @@ async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User + f'\n{subscription_link}\n\n' ) - installation_description = get_step_description(app, 'installationStep', db_user.language) - add_description = get_step_description(app, 'addSubscriptionStep', db_user.language) - connect_description = get_step_description(app, 'connectAndUseStep', db_user.language) - additional_before_text = format_additional_section( - app.get('additionalBeforeAddSubscriptionStep'), - texts, - db_user.language, - ) - additional_after_text = format_additional_section( - app.get('additionalAfterAddSubscriptionStep'), - texts, - db_user.language, - ) - guide_text = ( texts.t( 'SUBSCRIPTION_SPECIFIC_APP_TITLE', @@ -1491,27 +1545,71 @@ async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User + link_section ) - guide_text += texts.t('SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE', 'Шаг 1 - Установка:') - if installation_description: - guide_text += f'\n{installation_description}' + if is_blocks_format: + # Build guide from blocks + step_num = 1 + for block in app.get('blocks', []): + if not isinstance(block, dict): + continue + title = block.get('title', {}) + desc = block.get('description', {}) + title_text = get_localized_value(title, db_user.language) if isinstance(title, dict) else str(title or '') + desc_text = get_localized_value(desc, db_user.language) if isinstance(desc, dict) else str(desc or '') - if additional_before_text: - guide_text += f'\n\n{additional_before_text}' + if title_text or desc_text: + guide_text += f'Шаг {step_num}' + if title_text: + guide_text += f' - {title_text}' + guide_text += ':' + if desc_text: + guide_text += f'\n{desc_text}' + guide_text += '\n\n' + step_num += 1 + else: + raw_app = app.get('_raw', app) + installation_description = get_step_description(raw_app, 'installationStep', db_user.language) + add_description = get_step_description(raw_app, 'addSubscriptionStep', db_user.language) + connect_description = get_step_description(raw_app, 'connectAndUseStep', db_user.language) + additional_before_text = format_additional_section( + raw_app.get('additionalBeforeAddSubscriptionStep'), + texts, + db_user.language, + ) + additional_after_text = format_additional_section( + raw_app.get('additionalAfterAddSubscriptionStep'), + texts, + db_user.language, + ) - guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_ADD_TITLE', 'Шаг 2 - Добавление подписки:') - if add_description: - guide_text += f'\n{add_description}' + guide_text += texts.t('SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE', 'Шаг 1 - Установка:') + if installation_description: + guide_text += f'\n{installation_description}' - guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE', 'Шаг 3 - Подключение:') - if connect_description: - guide_text += f'\n{connect_description}' + if additional_before_text: + guide_text += f'\n\n{additional_before_text}' - if additional_after_text: - guide_text += f'\n\n{additional_after_text}' + guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_ADD_TITLE', 'Шаг 2 - Добавление подписки:') + if add_description: + guide_text += f'\n{add_description}' + + guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE', 'Шаг 3 - Подключение:') + if connect_description: + guide_text += f'\n{connect_description}' + + if additional_after_text: + guide_text += f'\n\n{additional_after_text}' + + keyboard_app = app.get('_raw', app) if not is_blocks_format else app await callback.message.edit_text( guide_text, - reply_markup=get_specific_app_keyboard(subscription_link, app, device_type, db_user.language), + reply_markup=get_specific_app_keyboard( + subscription_link, + keyboard_app, + device_type, + db_user.language, + is_blocks_format=is_blocks_format, + ), parse_mode='HTML', ) await callback.answer() diff --git a/app/handlers/subscription/links.py b/app/handlers/subscription/links.py index e15b1a1a..213262c3 100644 --- a/app/handlers/subscription/links.py +++ b/app/handlers/subscription/links.py @@ -16,6 +16,8 @@ from app.utils.subscription_utils import ( get_happ_cryptolink_redirect_link, ) +from .common import get_platforms_list, load_app_config_async, logger + async def handle_connect_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession): # Проверяем, доступно ли сообщение для редактирования @@ -144,6 +146,14 @@ async def handle_connect_subscription(callback: types.CallbackQuery, db_user: Us parse_mode='HTML', ) else: + # Guide mode: load config and build dynamic platform keyboard + platforms = None + try: + config = await load_app_config_async() + platforms = get_platforms_list(config) or None + except Exception as e: + logger.warning('Failed to load platforms for guide mode, using fallback', error=e) + if hide_subscription_link: device_text = texts.t( 'SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN', @@ -165,7 +175,9 @@ async def handle_connect_subscription(callback: types.CallbackQuery, db_user: Us ).format(subscription_url=subscription_link) await callback.message.edit_text( - device_text, reply_markup=get_device_selection_keyboard(db_user.language), parse_mode='HTML' + device_text, + reply_markup=get_device_selection_keyboard(db_user.language, platforms=platforms), + parse_mode='HTML', ) await callback.answer() diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index d5c88c74..96e6f643 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -223,6 +223,12 @@ def get_admin_settings_submenu_keyboard(language: str = 'ru') -> InlineKeyboardM callback_data='reqch:list', ) ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SETTINGS_APP_CONFIG', '📱 Конфиг приложений'), + callback_data='admin_remna_config', + ) + ], [InlineKeyboardButton(text=texts.BACK, callback_data='admin_panel')], ] ) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index ceca1f72..d59be2d0 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -2295,35 +2295,62 @@ def get_manage_countries_keyboard( return InlineKeyboardMarkup(inline_keyboard=buttons) -def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: +def get_device_selection_keyboard( + language: str = DEFAULT_LANGUAGE, + platforms: list[dict] | None = None, +) -> InlineKeyboardMarkup: from app.config import settings + from app.handlers.subscription.common import get_localized_value texts = get_texts(language) - keyboard = [ - [ - InlineKeyboardButton( - text=texts.t('DEVICE_GUIDE_IOS', '📱 iOS (iPhone/iPad)'), callback_data='device_guide_ios' - ), - InlineKeyboardButton( - text=texts.t('DEVICE_GUIDE_ANDROID', '🤖 Android'), callback_data='device_guide_android' - ), - ], - [ - InlineKeyboardButton( - text=texts.t('DEVICE_GUIDE_WINDOWS', '💻 Windows'), callback_data='device_guide_windows' - ), - InlineKeyboardButton(text=texts.t('DEVICE_GUIDE_MAC', '🎯 macOS'), callback_data='device_guide_mac'), - ], - [ - InlineKeyboardButton( - text=texts.t('DEVICE_GUIDE_ANDROID_TV', '📺 Android TV'), callback_data='device_guide_tv' - ), - InlineKeyboardButton( - text=texts.t('DEVICE_GUIDE_APPLE_TV', '📺 Apple TV'), callback_data='device_guide_appletv' - ), - ], - ] + keyboard: list[list[InlineKeyboardButton]] = [] + + if platforms: + # Dynamic platforms from Remnawave config + row: list[InlineKeyboardButton] = [] + for p in platforms: + display_name = p.get('displayName', p['key']) + if isinstance(display_name, dict): + display_name = get_localized_value(display_name, language) + emoji = p.get('icon_emoji', '📱') + device_type = p.get('device_type', p['key']) + btn = InlineKeyboardButton( + text=f'{emoji} {display_name}', + callback_data=f'device_guide_{device_type}', + ) + row.append(btn) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + else: + # Hardcoded fallback (legacy 6-device layout) + keyboard = [ + [ + InlineKeyboardButton( + text=texts.t('DEVICE_GUIDE_IOS', '📱 iOS (iPhone/iPad)'), callback_data='device_guide_ios' + ), + InlineKeyboardButton( + text=texts.t('DEVICE_GUIDE_ANDROID', '🤖 Android'), callback_data='device_guide_android' + ), + ], + [ + InlineKeyboardButton( + text=texts.t('DEVICE_GUIDE_WINDOWS', '💻 Windows'), callback_data='device_guide_windows' + ), + InlineKeyboardButton(text=texts.t('DEVICE_GUIDE_MAC', '🎯 macOS'), callback_data='device_guide_mac'), + ], + [ + InlineKeyboardButton( + text=texts.t('DEVICE_GUIDE_ANDROID_TV', '📺 Android TV'), callback_data='device_guide_tv' + ), + InlineKeyboardButton( + text=texts.t('DEVICE_GUIDE_APPLE_TV', '📺 Apple TV'), callback_data='device_guide_appletv' + ), + ], + ] if settings.CONNECT_BUTTON_MODE == 'guide': keyboard.append( @@ -2346,65 +2373,144 @@ def get_connection_guide_keyboard( device_type: str, language: str = DEFAULT_LANGUAGE, has_other_apps: bool = False, + *, + is_blocks_format: bool = False, ) -> InlineKeyboardMarkup: - from app.handlers.subscription import create_deep_link + from app.handlers.subscription.common import create_deep_link, get_localized_value, resolve_button_url texts = get_texts(language) - keyboard = [] + keyboard: list[list[InlineKeyboardButton]] = [] - if 'installationStep' in app and 'buttons' in app['installationStep']: - app_buttons = [] - for button in app['installationStep']['buttons']: - button_text = _get_localized_value(button.get('buttonText'), language) - button_link = button.get('buttonLink') - - if not button_text or not button_link: + if is_blocks_format and 'blocks' in app: + # Remnawave blocks format with colored buttons + for block in app.get('blocks', []): + if not isinstance(block, dict): continue + for btn in block.get('buttons', []): + if not isinstance(btn, dict): + continue + btn_type = btn.get('type', '') + btn_text = btn.get('text', {}) + if isinstance(btn_text, dict): + btn_text = get_localized_value(btn_text, language) + if not btn_text: + continue - app_buttons.append(InlineKeyboardButton(text=f'📥 {button_text}', url=button_link)) - if len(app_buttons) == 2: - keyboard.append(app_buttons) - app_buttons = [] + btn_url = btn.get('url', '') or btn.get('link', '') + resolved_url = btn.get('resolvedUrl', '') - if app_buttons: - keyboard.append(app_buttons) - - additional_before_buttons = _build_additional_buttons( - app.get('additionalBeforeAddSubscriptionStep'), - language, - ) - - for button in additional_before_buttons: - keyboard.append([button]) - - connect_link = create_deep_link(app, subscription_url) - - if connect_link: - connect_button = InlineKeyboardButton( - text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), - url=connect_link, - ) - elif settings.is_happ_cryptolink_mode(): - connect_button = InlineKeyboardButton( - text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), - callback_data='open_subscription_link', - ) + if btn_type == 'externalLink': + # Download button — blue (primary) + if btn_url: + keyboard.append( + [ + InlineKeyboardButton( + text=f'📥 {btn_text}', + url=btn_url, + style='primary', + ) + ] + ) + elif btn_type == 'subscriptionLink': + # Connect button — green (success) + url = resolved_url or resolve_button_url(btn_url, subscription_url) + deep_link = create_deep_link(app.get('_raw', app), subscription_url) + final_url = deep_link or url or subscription_url + if final_url: + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + url=final_url, + style='success', + ) + ] + ) + elif settings.is_happ_cryptolink_mode(): + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + callback_data='open_subscription_link', + style='success', + ) + ] + ) + elif btn_type == 'copyButton': + url = resolved_url or resolve_button_url(btn_url, subscription_url) + if url: + keyboard.append( + [ + InlineKeyboardButton( + text=f'📋 {btn_text}', + url=url, + ) + ] + ) else: - connect_button = InlineKeyboardButton( - text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), - url=subscription_url, + # Legacy step-based format + if 'installationStep' in app and 'buttons' in app['installationStep']: + app_buttons: list[InlineKeyboardButton] = [] + for button in app['installationStep']['buttons']: + button_text = _get_localized_value(button.get('buttonText'), language) + button_link = button.get('buttonLink') + + if not button_text or not button_link: + continue + + app_buttons.append( + InlineKeyboardButton( + text=f'📥 {button_text}', + url=button_link, + style='primary', + ) + ) + if len(app_buttons) == 2: + keyboard.append(app_buttons) + app_buttons = [] + + if app_buttons: + keyboard.append(app_buttons) + + additional_before_buttons = _build_additional_buttons( + app.get('additionalBeforeAddSubscriptionStep'), + language, ) - keyboard.append([connect_button]) + for button in additional_before_buttons: + keyboard.append([button]) - additional_after_buttons = _build_additional_buttons( - app.get('additionalAfterAddSubscriptionStep'), - language, - ) + connect_link = create_deep_link(app, subscription_url) - for button in additional_after_buttons: - keyboard.append([button]) + if connect_link: + connect_button = InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + url=connect_link, + style='success', + ) + elif settings.is_happ_cryptolink_mode(): + connect_button = InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + callback_data='open_subscription_link', + style='success', + ) + else: + connect_button = InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + url=subscription_url, + style='success', + ) + + keyboard.append([connect_button]) + + additional_after_buttons = _build_additional_buttons( + app.get('additionalAfterAddSubscriptionStep'), + language, + ) + + for button in additional_after_buttons: + keyboard.append([button]) if has_other_apps: keyboard.append( @@ -2466,90 +2572,23 @@ def get_app_selection_keyboard(device_type: str, apps: list, language: str = DEF def get_specific_app_keyboard( - subscription_url: str, app: dict, device_type: str, language: str = DEFAULT_LANGUAGE + subscription_url: str, + app: dict, + device_type: str, + language: str = DEFAULT_LANGUAGE, + *, + is_blocks_format: bool = False, ) -> InlineKeyboardMarkup: - from app.handlers.subscription import create_deep_link - - texts = get_texts(language) - - keyboard = [] - - if 'installationStep' in app and 'buttons' in app['installationStep']: - app_buttons = [] - for button in app['installationStep']['buttons']: - button_text = _get_localized_value(button.get('buttonText'), language) - button_link = button.get('buttonLink') - - if not button_text or not button_link: - continue - - app_buttons.append(InlineKeyboardButton(text=f'📥 {button_text}', url=button_link)) - if len(app_buttons) == 2: - keyboard.append(app_buttons) - app_buttons = [] - - if app_buttons: - keyboard.append(app_buttons) - - additional_before_buttons = _build_additional_buttons( - app.get('additionalBeforeAddSubscriptionStep'), + # Reuse the connection guide keyboard logic — same buttons, just always shows "Other apps" + return get_connection_guide_keyboard( + subscription_url, + app, + device_type, language, + has_other_apps=True, + is_blocks_format=is_blocks_format, ) - for button in additional_before_buttons: - keyboard.append([button]) - - connect_link = create_deep_link(app, subscription_url) - - if connect_link: - connect_button = InlineKeyboardButton( - text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), - url=connect_link, - ) - elif settings.is_happ_cryptolink_mode(): - connect_button = InlineKeyboardButton( - text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), - callback_data='open_subscription_link', - ) - else: - connect_button = InlineKeyboardButton( - text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), - url=subscription_url, - ) - - keyboard.append([connect_button]) - - additional_after_buttons = _build_additional_buttons( - app.get('additionalAfterAddSubscriptionStep'), - language, - ) - - for button in additional_after_buttons: - keyboard.append([button]) - - keyboard.extend( - [ - [ - InlineKeyboardButton( - text=texts.t('OTHER_APPS_BUTTON', '📋 Другие приложения'), callback_data=f'app_list_{device_type}' - ) - ], - [ - InlineKeyboardButton( - text=texts.t('CHOOSE_ANOTHER_DEVICE', '📱 Выбрать другое устройство'), - callback_data='subscription_connect', - ) - ], - [ - InlineKeyboardButton( - text=texts.t('BACK_TO_SUBSCRIPTION', '⬅️ К подписке'), callback_data='menu_subscription' - ) - ], - ] - ) - - return InlineKeyboardMarkup(inline_keyboard=keyboard) - def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> InlineKeyboardMarkup: texts = get_texts(language)