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
This commit is contained in:
Fringg
2026-02-12 22:34:38 +03:00
parent 9ac6da490d
commit bf2b2f1c56
7 changed files with 99 additions and 16 deletions

View File

@@ -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

View File

@@ -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'

View File

@@ -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:

View File

@@ -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', '📦 Тарифы (из кабинета)'),

View File

@@ -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)

View File

@@ -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',

10
uv.lock generated
View File

@@ -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" },