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)