From bf2b2f1c5650e527fcac0fb3e72b4e6e19bef406 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 12 Feb 2026 22:34:38 +0300 Subject: [PATCH] feat: add button style and emoji support for cabinet mode (Bot API 9.4) - Upgrade aiogram to 3.25.0 for style/icon_custom_emoji_id support - Add CABINET_BUTTON_STYLE config for global color override - Per-section default styles: subscription (green), balance (blue), referral (green), admin (red), home (blue) - Style priority: explicit > CABINET_BUTTON_STYLE > per-section default - Add icon_custom_emoji_id pass-through for Premium bot owners - Admin panel setting for button style with color picker --- .env.example | 6 +++ app/config.py | 2 + app/keyboards/inline.py | 39 +++++++++++++++----- app/services/system_settings_service.py | 7 ++++ app/utils/miniapp_buttons.py | 49 +++++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 10 ++--- 7 files changed, 99 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 17e9e9b4..37366091 100644 --- a/.env.example +++ b/.env.example @@ -704,6 +704,12 @@ LOGO_FILE=vpn_logo.png # Требует MINIAPP_CUSTOM_URL # Алиасы для обратной совместимости: text, text_only, minimal MAIN_MENU_MODE=default +# Стиль кнопок в режиме Cabinet (Bot API 9.4): +# primary - синий +# success - зелёный +# danger - красный +# (пустое) - цвета по умолчанию для каждой секции +CABINET_BUTTON_STYLE= # Включить управление меню через API (позволяет динамически менять структуру кнопок) MENU_LAYOUT_ENABLED=false diff --git a/app/config.py b/app/config.py index 80ad5046..04efa9b4 100644 --- a/app/config.py +++ b/app/config.py @@ -517,6 +517,8 @@ class Settings(BaseSettings): KASSA_AI_PAYMENT_SYSTEM_ID: int = 44 MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet' + # Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции) + CABINET_BUTTON_STYLE: str = '' CONNECT_BUTTON_MODE: str = 'miniapp_subscription' MINIAPP_CUSTOM_URL: str = '' MINIAPP_STATIC_PATH: str = 'miniapp' diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 34bd6709..345e3cc3 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -375,25 +375,44 @@ def _build_cabinet_main_menu_keyboard( Each button opens the corresponding section of the cabinet frontend via ``MINIAPP_CUSTOM_URL`` + path (e.g. ``/subscription``, ``/balance``). """ - from app.utils.miniapp_buttons import build_cabinet_url + from app.utils.miniapp_buttons import ( + CALLBACK_TO_CABINET_STYLE, + _resolve_style, + build_cabinet_url, + ) - def _cabinet_button(text: str, path: str, callback_fallback: str) -> InlineKeyboardButton: + global_style = (settings.CABINET_BUTTON_STYLE or '').strip() + + def _cabinet_button( + text: str, + path: str, + callback_fallback: str, + *, + style: str | None = None, + icon_custom_emoji_id: str | None = None, + ) -> InlineKeyboardButton: url = build_cabinet_url(path) if url: - return InlineKeyboardButton(text=text, web_app=types.WebAppInfo(url=url)) + resolved = _resolve_style(style or global_style or CALLBACK_TO_CABINET_STYLE.get(callback_fallback)) + return InlineKeyboardButton( + text=text, + web_app=types.WebAppInfo(url=url), + style=resolved, + icon_custom_emoji_id=icon_custom_emoji_id or None, + ) return InlineKeyboardButton(text=text, callback_data=callback_fallback) # -- Primary action row: Cabinet home -- profile_text = texts.t('MENU_PROFILE', '👤 Личный кабинет') keyboard_rows: list[list[InlineKeyboardButton]] = [ - [_cabinet_button(profile_text, '/', 'menu_profile_unavailable')], + [_cabinet_button(profile_text, '/', 'menu_profile_unavailable', style='primary')], ] # -- Section buttons as paired rows -- paired: list[InlineKeyboardButton] = [] - # Subscription - paired.append(_cabinet_button(texts.MENU_SUBSCRIPTION, '/subscription', 'menu_subscription')) + # Subscription (green — main action) + paired.append(_cabinet_button(texts.MENU_SUBSCRIPTION, '/subscription', 'menu_subscription', style='success')) # Balance safe_balance = balance_kopeks or 0 @@ -403,11 +422,11 @@ def _build_cabinet_main_menu_keyboard( balance_text = texts.t('BALANCE_BUTTON_DEFAULT', '💰 Баланс: {balance}').format( balance=texts.format_price(safe_balance), ) - paired.append(_cabinet_button(balance_text, '/balance', 'menu_balance')) + paired.append(_cabinet_button(balance_text, '/balance', 'menu_balance', style='primary')) # Referrals (if enabled) if settings.is_referral_program_enabled(): - paired.append(_cabinet_button(texts.MENU_REFERRALS, '/referral', 'menu_referrals')) + paired.append(_cabinet_button(texts.MENU_REFERRALS, '/referral', 'menu_referrals', style='success')) # Support support_enabled = False @@ -430,7 +449,7 @@ def _build_cabinet_main_menu_keyboard( ) ) - # Language selection (stays as callback - not a cabinet section) + # Language selection (stays as callback — not a cabinet section) if settings.is_language_selection_enabled(): paired.append(InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data='menu_language')) @@ -443,7 +462,7 @@ def _build_cabinet_main_menu_keyboard( keyboard_rows.append( [ InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel'), - _cabinet_button('🖥 Веб-Админка', '/admin', 'admin_panel'), + _cabinet_button('🖥 Веб-Админка', '/admin', 'admin_panel', style='danger'), ] ) elif is_moderator: diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 357cde13..eef645fc 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -294,6 +294,7 @@ class BotConfigurationService: 'LOGO_FILE': 'INTERFACE_BRANDING', 'HIDE_SUBSCRIPTION_LINK': 'INTERFACE_SUBSCRIPTION', 'MAIN_MENU_MODE': 'INTERFACE', + 'CABINET_BUTTON_STYLE': 'INTERFACE', 'CONNECT_BUTTON_MODE': 'CONNECT_BUTTON', 'MINIAPP_CUSTOM_URL': 'CONNECT_BUTTON', 'APP_CONFIG_PATH': 'ADDITIONAL', @@ -408,6 +409,12 @@ class BotConfigurationService: ChoiceOption('default', '📋 Полное меню'), ChoiceOption('cabinet', '🏠 Cabinet (МиниАпп)'), ], + 'CABINET_BUTTON_STYLE': [ + ChoiceOption('', '🎨 По секциям (авто)'), + ChoiceOption('primary', '🔵 Синий'), + ChoiceOption('success', '🟢 Зелёный'), + ChoiceOption('danger', '🔴 Красный'), + ], 'SALES_MODE': [ ChoiceOption('classic', '📋 Классический (периоды из .env)'), ChoiceOption('tariffs', '📦 Тарифы (из кабинета)'), diff --git a/app/utils/miniapp_buttons.py b/app/utils/miniapp_buttons.py index c7b0510b..0d02414e 100644 --- a/app/utils/miniapp_buttons.py +++ b/app/utils/miniapp_buttons.py @@ -27,6 +27,28 @@ CALLBACK_TO_CABINET_PATH: dict[str, str] = { 'back_to_menu': '/', } +# Default button styles per callback_data for cabinet mode. +# Values: 'primary' (blue), 'success' (green), 'danger' (red), None (default). +CALLBACK_TO_CABINET_STYLE: dict[str, str] = { + 'menu_balance': 'primary', + 'balance_topup': 'primary', + 'menu_subscription': 'success', + 'subscription': 'success', + 'subscription_extend': 'success', + 'subscription_upgrade': 'success', + 'subscription_connect': 'success', + 'subscription_resume_checkout': 'success', + 'return_to_saved_cart': 'success', + 'menu_buy': 'success', + 'buy_traffic': 'success', + 'menu_referrals': 'success', + 'menu_referral': 'success', + 'menu_support': 'primary', + 'menu_info': 'primary', + 'menu_profile': 'primary', + 'back_to_menu': 'primary', +} + # Mapping from broadcast button keys to cabinet paths. BUTTON_KEY_TO_CABINET_PATH: dict[str, str] = { 'balance': '/balance/top-up', @@ -38,6 +60,16 @@ BUTTON_KEY_TO_CABINET_PATH: dict[str, str] = { 'home': '/', } +# Valid style values accepted by the Telegram Bot API. +_VALID_STYLES = frozenset({'primary', 'success', 'danger'}) + + +def _resolve_style(style: str | None) -> str | None: + """Return a validated style or ``None``.""" + if style and style in _VALID_STYLES: + return style + return None + def build_cabinet_url(path: str = '') -> str: """Join ``MINIAPP_CUSTOM_URL`` with an optional *path* segment. @@ -66,6 +98,8 @@ def build_miniapp_or_callback_button( *, callback_data: str, cabinet_path: str | None = None, + style: str | None = None, + icon_custom_emoji_id: str | None = None, ) -> InlineKeyboardButton: """Create a button that opens the cabinet miniapp or falls back to a callback. @@ -74,6 +108,13 @@ def build_miniapp_or_callback_button( by ``cabinet_path`` (explicit) or inferred from ``callback_data`` via ``CALLBACK_TO_CABINET_PATH``. + Button styling (Bot API 9.4): + - ``style`` overrides the button color: ``'primary'`` (blue), + ``'success'`` (green), ``'danger'`` (red). When omitted the style is + resolved from ``CABINET_BUTTON_STYLE`` config or per-section defaults. + - ``icon_custom_emoji_id`` shows a custom emoji before the button text + (requires bot owner to have Telegram Premium). + When ``callback_data`` is not found in the mapping and no explicit ``cabinet_path`` is given, the button falls back to a regular Telegram callback — this keeps actions like ``claim_discount_*`` working correctly. @@ -88,9 +129,17 @@ def build_miniapp_or_callback_button( if path: url = build_cabinet_url(path) if url: + # Resolve style: explicit > global config > per-section default + resolved_style = _resolve_style( + style + or (settings.CABINET_BUTTON_STYLE or '').strip() + or CALLBACK_TO_CABINET_STYLE.get(callback_data) + ) return InlineKeyboardButton( text=text, web_app=types.WebAppInfo(url=url), + style=resolved_style, + icon_custom_emoji_id=icon_custom_emoji_id or None, ) return InlineKeyboardButton(text=text, callback_data=callback_data) diff --git a/pyproject.toml b/pyproject.toml index 39237089..e2b40542 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = 'README.md' license = { text = 'MIT' } requires-python = '==3.13.*' dependencies = [ - 'aiogram>=3.22.0', + "aiogram>=3.25.0", 'sqlalchemy>=2.0.43', 'alembic>=1.16.5', 'asyncpg>=0.30.0', diff --git a/uv.lock b/uv.lock index 533dff31..49977a8f 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "aiogram" -version = "3.24.0" +version = "3.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -23,9 +23,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/2f/04f47e81def8f2168679b1551e665e7ee02cf063e7bddace9fb5d1ce2f35/aiogram-3.24.0.tar.gz", hash = "sha256:ec547ede5bfa8a7a4f5fb02c75391333fc43b6f3de6a6d3f00a32e27628df5f6", size = 1713321, upload-time = "2026-01-02T00:56:55.3Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/31/360c4ce76e60e9e7bcdda1af1ab4331d78837fbb22847a62121ad32b7672/aiogram-3.25.0.tar.gz", hash = "sha256:8a8b0c34f8c4ca8a6501b954abb0eeba26743449e35e20b70c0d810347354c3c", size = 1721010, upload-time = "2026-02-10T21:50:25.473Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/a5/7ba5f75b56f87a956b9e5a3e823bcbb5b55fc968914a16f3c7aa659cfc89/aiogram-3.24.0-py3-none-any.whl", hash = "sha256:eb3cc05b0ec53c7e24d7eada5c069aee2f431332e2e7bc2c8adf30d13b02f715", size = 706866, upload-time = "2026-01-02T00:56:53.115Z" }, + { url = "https://files.pythonhosted.org/packages/cf/be/1090252415e192687985517162dbdcee2ec4150cda1fa52bf57ae1f1c2a8/aiogram-3.25.0-py3-none-any.whl", hash = "sha256:0243966e93fbde14e90c0dfd0b3776c637ebf7ddcca2c7ee81ecbd68d9490cce", size = 713972, upload-time = "2026-02-10T21:50:23.253Z" }, ] [[package]] @@ -1114,7 +1114,7 @@ wheels = [ [[package]] name = "remnawave-bedolaga-telegram-bot" -version = "3.8.0" +version = "3.10.0" source = { virtual = "." } dependencies = [ { name = "aiogram" }, @@ -1145,7 +1145,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aiogram", specifier = ">=3.22.0" }, + { name = "aiogram", specifier = ">=3.25.0" }, { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "alembic", specifier = ">=1.16.5" }, { name = "asyncpg", specifier = ">=0.30.0" },