mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-02 00:03:05 +00:00
feat: add per-section button style and emoji customization via admin API
Add cabinet admin API for configuring button colors (primary/success/danger) and custom emoji IDs per menu section (home, subscription, balance, referral, support, info, admin). Styles are stored as JSON in system_settings and cached in-process for fast resolution. Style resolution chain: explicit param > per-section DB > global config > defaults.
This commit is contained in:
@@ -238,6 +238,15 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
elif settings.is_cabinet_mode():
|
||||
logger.info(f'🏠 Режим Cabinet активен, базовый URL: {settings.MINIAPP_CUSTOM_URL}')
|
||||
|
||||
# Load per-section button styles cache
|
||||
if settings.is_cabinet_mode():
|
||||
try:
|
||||
from app.utils.button_styles_cache import load_button_styles_cache
|
||||
|
||||
await load_button_styles_cache()
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to load button styles cache: {e}')
|
||||
|
||||
logger.info('Бот успешно настроен')
|
||||
|
||||
return bot, dp
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter
|
||||
from .admin_apps import router as admin_apps_router
|
||||
from .admin_ban_system import router as admin_ban_system_router
|
||||
from .admin_broadcasts import router as admin_broadcasts_router
|
||||
from .admin_button_styles import router as admin_button_styles_router
|
||||
from .admin_campaigns import router as admin_campaigns_router
|
||||
from .admin_email_templates import router as admin_email_templates_router
|
||||
from .admin_payment_methods import router as admin_payment_methods_router
|
||||
@@ -91,6 +92,7 @@ router.include_router(admin_email_templates_router)
|
||||
router.include_router(admin_updates_router)
|
||||
router.include_router(admin_traffic_router)
|
||||
router.include_router(admin_pinned_messages_router)
|
||||
router.include_router(admin_button_styles_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
195
app/cabinet/routes/admin_button_styles.py
Normal file
195
app/cabinet/routes/admin_button_styles.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Admin routes for per-section cabinet button style configuration."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.utils.button_styles_cache import (
|
||||
BUTTON_STYLES_KEY,
|
||||
DEFAULT_BUTTON_STYLES,
|
||||
SECTIONS,
|
||||
VALID_STYLES,
|
||||
load_button_styles_cache,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/button-styles', tags=['Admin Button Styles'])
|
||||
|
||||
|
||||
# ---- Schemas ---------------------------------------------------------------
|
||||
|
||||
|
||||
class ButtonSectionConfig(BaseModel):
|
||||
"""Configuration for a single button section."""
|
||||
|
||||
style: str = 'primary'
|
||||
icon_custom_emoji_id: str = ''
|
||||
|
||||
|
||||
class ButtonStylesResponse(BaseModel):
|
||||
"""Full button styles configuration (all 7 sections)."""
|
||||
|
||||
home: ButtonSectionConfig = ButtonSectionConfig()
|
||||
subscription: ButtonSectionConfig = ButtonSectionConfig()
|
||||
balance: ButtonSectionConfig = ButtonSectionConfig()
|
||||
referral: ButtonSectionConfig = ButtonSectionConfig()
|
||||
support: ButtonSectionConfig = ButtonSectionConfig()
|
||||
info: ButtonSectionConfig = ButtonSectionConfig()
|
||||
admin: ButtonSectionConfig = ButtonSectionConfig()
|
||||
|
||||
|
||||
class ButtonSectionUpdate(BaseModel):
|
||||
"""Partial update for a single section (None = keep current)."""
|
||||
|
||||
style: str | None = None
|
||||
icon_custom_emoji_id: str | None = None
|
||||
|
||||
|
||||
class ButtonStylesUpdate(BaseModel):
|
||||
"""Partial update — only include sections you want to change."""
|
||||
|
||||
home: ButtonSectionUpdate | None = None
|
||||
subscription: ButtonSectionUpdate | None = None
|
||||
balance: ButtonSectionUpdate | None = None
|
||||
referral: ButtonSectionUpdate | None = None
|
||||
support: ButtonSectionUpdate | None = None
|
||||
info: ButtonSectionUpdate | None = None
|
||||
admin: ButtonSectionUpdate | None = None
|
||||
|
||||
|
||||
# ---- Helpers ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
return setting.value if setting else None
|
||||
|
||||
|
||||
async def _set_setting_value(db: AsyncSession, key: str, value: str) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
setting = SystemSetting(key=key, value=value)
|
||||
db.add(setting)
|
||||
await db.commit()
|
||||
|
||||
|
||||
def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
|
||||
return ButtonStylesResponse(
|
||||
**{section: ButtonSectionConfig(**cfg) for section, cfg in styles.items() if section in SECTIONS},
|
||||
)
|
||||
|
||||
|
||||
# ---- Routes ----------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get('', response_model=ButtonStylesResponse)
|
||||
async def get_button_styles(
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return current per-section button styles. Admin only."""
|
||||
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
|
||||
merged = {section: {**cfg} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
|
||||
|
||||
if raw:
|
||||
try:
|
||||
db_data = json.loads(raw)
|
||||
for section, overrides in db_data.items():
|
||||
if section in merged and isinstance(overrides, dict):
|
||||
if overrides.get('style') in VALID_STYLES:
|
||||
merged[section]['style'] = overrides['style']
|
||||
if isinstance(overrides.get('icon_custom_emoji_id'), str):
|
||||
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return _build_response(merged)
|
||||
|
||||
|
||||
@router.patch('', response_model=ButtonStylesResponse)
|
||||
async def update_button_styles(
|
||||
payload: ButtonStylesUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Partially update per-section button styles. Admin only."""
|
||||
# Load current state
|
||||
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
|
||||
current: dict[str, dict] = {section: {**cfg} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
|
||||
|
||||
if raw:
|
||||
try:
|
||||
db_data = json.loads(raw)
|
||||
for section, overrides in db_data.items():
|
||||
if section in current and isinstance(overrides, dict):
|
||||
current[section].update(overrides)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Apply updates
|
||||
update_data = payload.model_dump(exclude_none=True)
|
||||
changed_sections: list[str] = []
|
||||
|
||||
for section, updates in update_data.items():
|
||||
if section not in current or not isinstance(updates, dict):
|
||||
continue
|
||||
|
||||
if 'style' in updates:
|
||||
style_val = updates['style']
|
||||
if style_val not in VALID_STYLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid style "{style_val}" for section "{section}". '
|
||||
f'Allowed: {", ".join(sorted(VALID_STYLES))}',
|
||||
)
|
||||
current[section]['style'] = style_val
|
||||
|
||||
if 'icon_custom_emoji_id' in updates:
|
||||
emoji_val = (updates['icon_custom_emoji_id'] or '').strip()
|
||||
current[section]['icon_custom_emoji_id'] = emoji_val
|
||||
|
||||
changed_sections.append(section)
|
||||
|
||||
# Persist
|
||||
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(current))
|
||||
|
||||
# Refresh in-process cache
|
||||
await load_button_styles_cache()
|
||||
|
||||
logger.info('Admin %s updated button styles for sections: %s', admin.telegram_id, changed_sections)
|
||||
|
||||
return _build_response(current)
|
||||
|
||||
|
||||
@router.post('/reset', response_model=ButtonStylesResponse)
|
||||
async def reset_button_styles(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset all button styles to defaults. Admin only."""
|
||||
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
|
||||
await load_button_styles_cache()
|
||||
|
||||
logger.info('Admin %s reset button styles to defaults', admin.telegram_id)
|
||||
|
||||
return _build_response(DEFAULT_BUTTON_STYLES)
|
||||
@@ -375,6 +375,7 @@ 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.button_styles_cache import CALLBACK_TO_SECTION, get_cached_button_styles
|
||||
from app.utils.miniapp_buttons import (
|
||||
CALLBACK_TO_CABINET_STYLE,
|
||||
_resolve_style,
|
||||
@@ -382,6 +383,7 @@ def _build_cabinet_main_menu_keyboard(
|
||||
)
|
||||
|
||||
global_style = _resolve_style((settings.CABINET_BUTTON_STYLE or '').strip())
|
||||
cached_styles = get_cached_button_styles()
|
||||
|
||||
def _cabinet_button(
|
||||
text: str,
|
||||
@@ -393,26 +395,36 @@ def _build_cabinet_main_menu_keyboard(
|
||||
) -> InlineKeyboardButton:
|
||||
url = build_cabinet_url(path)
|
||||
if url:
|
||||
resolved = _resolve_style(style or global_style or CALLBACK_TO_CABINET_STYLE.get(callback_fallback))
|
||||
section = CALLBACK_TO_SECTION.get(callback_fallback)
|
||||
section_cfg = cached_styles.get(section or '', {}) if section else {}
|
||||
|
||||
resolved = _resolve_style(
|
||||
style
|
||||
or _resolve_style(section_cfg.get('style'))
|
||||
or global_style
|
||||
or CALLBACK_TO_CABINET_STYLE.get(callback_fallback)
|
||||
)
|
||||
resolved_emoji = icon_custom_emoji_id or section_cfg.get('icon_custom_emoji_id') or None
|
||||
|
||||
return InlineKeyboardButton(
|
||||
text=text,
|
||||
web_app=types.WebAppInfo(url=url),
|
||||
style=resolved,
|
||||
icon_custom_emoji_id=icon_custom_emoji_id or None,
|
||||
icon_custom_emoji_id=resolved_emoji 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', style='primary')],
|
||||
[_cabinet_button(profile_text, '/', 'menu_profile_unavailable')],
|
||||
]
|
||||
|
||||
# -- Section buttons as paired rows --
|
||||
paired: list[InlineKeyboardButton] = []
|
||||
|
||||
# Subscription (green — main action)
|
||||
paired.append(_cabinet_button(texts.MENU_SUBSCRIPTION, '/subscription', 'menu_subscription', style='success'))
|
||||
paired.append(_cabinet_button(texts.MENU_SUBSCRIPTION, '/subscription', 'menu_subscription'))
|
||||
|
||||
# Balance
|
||||
safe_balance = balance_kopeks or 0
|
||||
@@ -422,11 +434,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', style='primary'))
|
||||
paired.append(_cabinet_button(balance_text, '/balance', 'menu_balance'))
|
||||
|
||||
# Referrals (if enabled)
|
||||
if settings.is_referral_program_enabled():
|
||||
paired.append(_cabinet_button(texts.MENU_REFERRALS, '/referral', 'menu_referrals', style='success'))
|
||||
paired.append(_cabinet_button(texts.MENU_REFERRALS, '/referral', 'menu_referrals'))
|
||||
|
||||
# Support
|
||||
support_enabled = False
|
||||
@@ -462,7 +474,7 @@ def _build_cabinet_main_menu_keyboard(
|
||||
keyboard_rows.append(
|
||||
[
|
||||
InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel'),
|
||||
_cabinet_button('🖥 Веб-Админка', '/admin', 'admin_panel', style='danger'),
|
||||
_cabinet_button('🖥 Веб-Админка', '/admin', 'admin_panel'),
|
||||
]
|
||||
)
|
||||
elif is_moderator:
|
||||
|
||||
102
app/utils/button_styles_cache.py
Normal file
102
app/utils/button_styles_cache.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Lightweight in-process cache for per-section cabinet button styles.
|
||||
|
||||
Avoids circular imports between ``cabinet.routes`` and ``app.utils.miniapp_buttons``
|
||||
by keeping the cache and its helpers in a dedicated module.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---- Defaults per section ------------------------------------------------
|
||||
|
||||
DEFAULT_BUTTON_STYLES: dict[str, dict] = {
|
||||
'home': {'style': 'primary', 'icon_custom_emoji_id': ''},
|
||||
'subscription': {'style': 'success', 'icon_custom_emoji_id': ''},
|
||||
'balance': {'style': 'primary', 'icon_custom_emoji_id': ''},
|
||||
'referral': {'style': 'success', 'icon_custom_emoji_id': ''},
|
||||
'support': {'style': 'primary', 'icon_custom_emoji_id': ''},
|
||||
'info': {'style': 'primary', 'icon_custom_emoji_id': ''},
|
||||
'admin': {'style': 'danger', 'icon_custom_emoji_id': ''},
|
||||
}
|
||||
|
||||
SECTIONS = list(DEFAULT_BUTTON_STYLES.keys())
|
||||
|
||||
# Map callback_data values to their logical section name.
|
||||
CALLBACK_TO_SECTION: dict[str, str] = {
|
||||
'menu_profile_unavailable': 'home',
|
||||
'back_to_menu': 'home',
|
||||
'menu_subscription': 'subscription',
|
||||
'subscription': 'subscription',
|
||||
'subscription_extend': 'subscription',
|
||||
'subscription_upgrade': 'subscription',
|
||||
'subscription_connect': 'subscription',
|
||||
'subscription_resume_checkout': 'subscription',
|
||||
'return_to_saved_cart': 'subscription',
|
||||
'menu_buy': 'subscription',
|
||||
'buy_traffic': 'subscription',
|
||||
'menu_balance': 'balance',
|
||||
'balance_topup': 'balance',
|
||||
'menu_referrals': 'referral',
|
||||
'menu_referral': 'referral',
|
||||
'menu_support': 'support',
|
||||
'menu_info': 'info',
|
||||
'admin_panel': 'admin',
|
||||
}
|
||||
|
||||
# DB key used for storage.
|
||||
BUTTON_STYLES_KEY = 'CABINET_BUTTON_STYLES'
|
||||
|
||||
# Valid Telegram Bot API style values.
|
||||
VALID_STYLES = frozenset({'primary', 'success', 'danger'})
|
||||
|
||||
# ---- Module-level cache ---------------------------------------------------
|
||||
|
||||
_cached_styles: dict[str, dict] | None = None
|
||||
|
||||
|
||||
def get_cached_button_styles() -> dict[str, dict]:
|
||||
"""Return the current merged config (DB overrides + defaults).
|
||||
|
||||
If the cache has not been loaded yet, returns defaults.
|
||||
"""
|
||||
if _cached_styles is not None:
|
||||
return _cached_styles
|
||||
return {section: {**cfg} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
|
||||
|
||||
|
||||
async def load_button_styles_cache() -> dict[str, dict]:
|
||||
"""Load button styles from DB and refresh the module cache.
|
||||
|
||||
Called at bot startup and after admin updates via the cabinet API.
|
||||
"""
|
||||
global _cached_styles
|
||||
|
||||
merged = {section: {**cfg} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
|
||||
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(SystemSetting).where(SystemSetting.key == BUTTON_STYLES_KEY))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting and setting.value:
|
||||
db_data: dict = json.loads(setting.value)
|
||||
for section, overrides in db_data.items():
|
||||
if section in merged and isinstance(overrides, dict):
|
||||
if overrides.get('style') in VALID_STYLES:
|
||||
merged[section]['style'] = overrides['style']
|
||||
if isinstance(overrides.get('icon_custom_emoji_id'), str):
|
||||
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
|
||||
except Exception:
|
||||
logger.exception('Failed to load button styles from DB, using defaults')
|
||||
|
||||
_cached_styles = merged
|
||||
logger.info('Button styles cache loaded: %s', list(merged.keys()))
|
||||
return merged
|
||||
@@ -2,6 +2,7 @@ from aiogram import types
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.button_styles_cache import CALLBACK_TO_SECTION, get_cached_button_styles
|
||||
|
||||
|
||||
# Mapping from callback_data to cabinet frontend paths.
|
||||
@@ -129,17 +130,26 @@ def build_miniapp_or_callback_button(
|
||||
if path:
|
||||
url = build_cabinet_url(path)
|
||||
if url:
|
||||
# Resolve style: explicit > global config > per-section default
|
||||
# Resolve per-section config from cache
|
||||
section = CALLBACK_TO_SECTION.get(callback_data)
|
||||
section_cfg = get_cached_button_styles().get(section or '', {}) if section else {}
|
||||
|
||||
# Style chain: explicit param > per-section DB > global config > hardcoded default
|
||||
resolved_style = _resolve_style(
|
||||
style
|
||||
or _resolve_style(section_cfg.get('style'))
|
||||
or _resolve_style((settings.CABINET_BUTTON_STYLE or '').strip())
|
||||
or CALLBACK_TO_CABINET_STYLE.get(callback_data)
|
||||
)
|
||||
|
||||
# Emoji chain: explicit param > per-section DB
|
||||
resolved_emoji = icon_custom_emoji_id or section_cfg.get('icon_custom_emoji_id') or None
|
||||
|
||||
return InlineKeyboardButton(
|
||||
text=text,
|
||||
web_app=types.WebAppInfo(url=url),
|
||||
style=resolved_style,
|
||||
icon_custom_emoji_id=icon_custom_emoji_id or None,
|
||||
icon_custom_emoji_id=resolved_emoji or None,
|
||||
)
|
||||
|
||||
return InlineKeyboardButton(text=text, callback_data=callback_data)
|
||||
|
||||
Reference in New Issue
Block a user