Files
remnawave-bedolaga-telegram…/app/utils/menu_layout_cache.py
T

192 lines
6.4 KiB
Python

"""Lightweight in-process cache for cabinet menu row layout configuration.
Stores per-row button arrangement (which buttons per row, max_per_row)
and custom URL buttons. Loaded from SystemSetting key ``CABINET_MENU_LAYOUT``.
"""
import json
import structlog
from app.database.database import AsyncSessionLocal
logger = structlog.get_logger(__name__)
# ---- Constants ---------------------------------------------------------------
MENU_LAYOUT_KEY = 'CABINET_MENU_LAYOUT'
BUILTIN_SECTIONS: tuple[str, ...] = (
'home',
'subscription',
'balance',
'referral',
'support',
'info',
'admin',
'language',
)
VALID_MAX_PER_ROW = frozenset({1, 2, 3})
# Valid Telegram Bot API style values for custom buttons.
VALID_CUSTOM_BUTTON_STYLES = frozenset({'primary', 'success', 'danger', 'default'})
DEFAULT_MENU_LAYOUT: dict[str, object] = {
'row_1': {'id': 'row_1', 'buttons': ['home'], 'max_per_row': 1},
'row_2': {'id': 'row_2', 'buttons': ['subscription', 'balance'], 'max_per_row': 2},
'row_3': {'id': 'row_3', 'buttons': ['referral', 'support'], 'max_per_row': 2},
'row_4': {'id': 'row_4', 'buttons': ['info', 'language'], 'max_per_row': 2},
'row_5': {'id': 'row_5', 'buttons': ['admin'], 'max_per_row': 1},
'custom_buttons': {},
}
# ---- Module-level cache ------------------------------------------------------
_cached_layout: dict[str, object] | None = None
def _deep_copy_layout(source: dict[str, object]) -> dict[str, object]:
"""Return a deep copy of layout dict via JSON round-trip."""
return json.loads(json.dumps(source))
def get_cached_menu_layout() -> dict[str, object]:
"""Return the current layout config (DB overrides + defaults).
If the cache has not been loaded yet, returns defaults.
"""
if _cached_layout is not None:
return _deep_copy_layout(_cached_layout)
return _deep_copy_layout(DEFAULT_MENU_LAYOUT)
def _validate_row(row_id: str, data: dict) -> dict | None:
"""Validate and sanitize a single row entry. Returns cleaned dict or None."""
if not isinstance(data, dict):
return None
buttons = data.get('buttons')
if not isinstance(buttons, list) or not buttons:
return None
# Allow known built-in section names AND custom_* button IDs in rows
clean_buttons = [b for b in buttons if isinstance(b, str) and (b in BUILTIN_SECTIONS or b.startswith('custom_'))]
if not clean_buttons:
return None
max_per_row = data.get('max_per_row')
if not isinstance(max_per_row, int) or max_per_row not in VALID_MAX_PER_ROW:
max_per_row = 1
return {'id': row_id, 'buttons': clean_buttons, 'max_per_row': max_per_row}
def _validate_custom_button(btn_id: str, data: dict) -> dict | None:
"""Validate and sanitize a single custom URL button. Returns cleaned dict or None."""
if not isinstance(data, dict):
return None
if not btn_id.startswith('custom_'):
return None
url = data.get('url')
if not isinstance(url, str) or not url.strip():
return None
style = data.get('style', 'primary')
if style not in VALID_CUSTOM_BUTTON_STYLES:
style = 'primary'
labels = data.get('labels')
if not isinstance(labels, dict):
labels = {}
clean_labels = {k: v for k, v in labels.items() if isinstance(k, str) and isinstance(v, str)}
icon_custom_emoji_id = data.get('icon_custom_emoji_id', '')
if not isinstance(icon_custom_emoji_id, str):
icon_custom_emoji_id = ''
enabled = data.get('enabled', True)
if not isinstance(enabled, bool):
enabled = True
open_in = data.get('open_in', 'external')
if open_in not in ('external', 'webapp'):
open_in = 'external'
if open_in == 'webapp' and not url.strip().startswith('https://'):
open_in = 'external'
return {
'id': btn_id,
'url': url.strip(),
'style': style,
'labels': clean_labels,
'icon_custom_emoji_id': icon_custom_emoji_id,
'enabled': enabled,
'open_in': open_in,
}
def _validate_layout(data: dict) -> dict[str, object]:
"""Validate and sanitize full layout data from DB.
Returns a clean layout dict; invalid entries are silently dropped.
"""
result: dict[str, object] = {}
for key, value in data.items():
if key == 'custom_buttons':
if isinstance(value, dict):
clean_customs: dict[str, dict] = {}
for btn_id, btn_data in value.items():
validated = _validate_custom_button(str(btn_id), btn_data)
if validated is not None:
clean_customs[str(btn_id)] = validated
result['custom_buttons'] = clean_customs
elif key.startswith('row_'):
validated_row = _validate_row(key, value)
if validated_row is not None:
result[key] = validated_row
# Ensure custom_buttons key always exists
if 'custom_buttons' not in result:
result['custom_buttons'] = {}
return result
async def load_menu_layout_cache() -> dict[str, object]:
"""Load menu layout from DB and refresh the module cache.
Called at bot startup and after admin updates via the cabinet API.
"""
global _cached_layout
merged = _deep_copy_layout(DEFAULT_MENU_LAYOUT)
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 == MENU_LAYOUT_KEY))
setting = result.scalar_one_or_none()
if setting and setting.value:
db_data: dict = json.loads(setting.value)
if isinstance(db_data, dict):
validated = _validate_layout(db_data)
if validated and any(k.startswith('row_') for k in validated):
# Replace rows and custom_buttons from DB only if at least one row exists
merged = validated
# Ensure custom_buttons always present
if 'custom_buttons' not in merged:
merged['custom_buttons'] = {}
except Exception:
logger.exception('Failed to load menu layout from DB, using defaults')
_cached_layout = merged
logger.info('Menu layout cache loaded', rows=len([k for k in merged if k.startswith('row_')]))
return merged