diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py index 96e93be8..b129ea68 100644 --- a/app/handlers/admin/bot_configuration.py +++ b/app/handlers/admin/bot_configuration.py @@ -1,21 +1,13 @@ -import io -import json import math -import re import time -from dataclasses import dataclass -from datetime import datetime -from typing import Any, Dict, Iterable, List, Optional, Tuple +from typing import Iterable, List, Tuple from aiogram import Dispatcher, F, types from aiogram.filters import BaseFilter, StateFilter from aiogram.fsm.context import FSMContext -from aiogram.types import BufferedInputFile -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import SystemSetting, User -from app.database.crud.system_setting import delete_system_setting, upsert_system_setting +from app.database.models import User from app.localization.texts import get_texts from app.config import settings from app.services.remnawave_service import RemnaWaveService @@ -28,230 +20,80 @@ from app.utils.currency_converter import currency_converter from app.external.telegram_stars import TelegramStarsService +CATEGORY_PAGE_SIZE = 10 SETTINGS_PAGE_SIZE = 8 -@dataclass(frozen=True) -class SpecCategory: - key: str - title: str - description: str - icon: str - category_keys: Tuple[str, ...] - - -SPEC_CATEGORIES: Tuple[SpecCategory, ...] = ( - SpecCategory( - key="core", - title="🤖 Основные", - description="Базовые настройки бота и поведение по умолчанию.", - icon="🤖", - category_keys=("CHANNEL",), +CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = ( + ( + "core", + "⚙️ Основные настройки", + ("SUPPORT", "LOCALIZATION", "MAINTENANCE"), ), - SpecCategory( - key="support", - title="💬 Поддержка", - description="Система тикетов, контакты и SLA.", - icon="💬", - category_keys=("SUPPORT",), + ( + "channels_notifications", + "📢 Каналы и уведомления", + ("CHANNEL", "ADMIN_NOTIFICATIONS", "ADMIN_REPORTS"), ), - SpecCategory( - key="payments", - title="💳 Платежные системы", - description="Управление всеми способами оплаты и текстами чеков.", - icon="💳", - category_keys=("PAYMENT", "TELEGRAM", "CRYPTOBOT", "YOOKASSA", "TRIBUTE", "MULENPAY", "PAL24"), + ( + "subscriptions", + "💎 Подписки и тарифы", + ("TRIAL", "PAID_SUBSCRIPTION", "PERIODS", "SUBSCRIPTION_PRICES", "TRAFFIC", "TRAFFIC_PACKAGES", "DISCOUNTS"), ), - SpecCategory( - key="subscriptions", - title="📅 Подписки и цены", - description="Периоды, тарифы, трафик и автопродление.", - icon="📅", - category_keys=( - "PAID_SUBSCRIPTION", - "PERIODS", - "SUBSCRIPTION_PRICES", - "TRAFFIC", - "TRAFFIC_PACKAGES", - "DISCOUNTS", - "AUTOPAY", - ), + ( + "payments", + "💳 Платежные системы", + ("PAYMENT", "TELEGRAM", "CRYPTOBOT", "YOOKASSA", "TRIBUTE", "MULENPAY", "PAL24"), ), - SpecCategory( - key="trial", - title="🎁 Пробный период", - description="Настройки бесплатного доступа и ограничений.", - icon="🎁", - category_keys=("TRIAL",), + ( + "remnawave", + "🔗 RemnaWave API", + ("REMNAWAVE",), ), - SpecCategory( - key="referral", - title="👥 Реферальная программа", - description="Бонусы, комиссии и уведомления за приглашения.", - icon="👥", - category_keys=("REFERRAL",), + ( + "referral", + "🤝 Реферальная система", + ("REFERRAL",), ), - SpecCategory( - key="notifications", - title="🔔 Уведомления", - description="Админ-уведомления, отчеты и SLA.", - icon="🔔", - category_keys=("ADMIN_NOTIFICATIONS", "ADMIN_REPORTS", "NOTIFICATIONS"), + ( + "autopay", + "🔄 Автопродление", + ("AUTOPAY",), ), - SpecCategory( - key="branding", - title="🎨 Интерфейс и брендинг", - description="Логотип, тексты, языки и Mini App.", - icon="🎨", - category_keys=( - "LOCALIZATION", - "INTERFACE_BRANDING", - "INTERFACE_SUBSCRIPTION", - "CONNECT_BUTTON", - "HAPP", - "SKIP", - "ADDITIONAL", - "MINIAPP", - ), + ( + "interface", + "🎨 Интерфейс и UX", + ("INTERFACE_BRANDING", "INTERFACE_SUBSCRIPTION", "CONNECT_BUTTON", "HAPP", "SKIP", "ADDITIONAL"), ), - SpecCategory( - key="database", - title="💾 База данных", - description="Настройки PostgreSQL, SQLite и Redis.", - icon="💾", - category_keys=("DATABASE", "POSTGRES", "SQLITE", "REDIS"), + ( + "database", + "🗄️ База данных", + ("DATABASE", "POSTGRES", "SQLITE", "REDIS"), ), - SpecCategory( - key="remnawave", - title="🌐 RemnaWave API", - description="Интеграция с панелью RemnaWave и тест соединения.", - icon="🌐", - category_keys=("REMNAWAVE",), + ( + "monitoring", + "📊 Мониторинг", + ("MONITORING", "NOTIFICATIONS", "SERVER"), ), - SpecCategory( - key="servers", - title="📊 Статус серверов", - description="Мониторинг инфраструктуры и внешние метрики.", - icon="📊", - category_keys=("SERVER", "MONITORING"), + ( + "backup", + "💾 Система бэкапов", + ("BACKUP",), ), - SpecCategory( - key="maintenance", - title="🔧 Обслуживание", - description="Техработы, резервные копии и проверки обновлений.", - icon="🔧", - category_keys=("MAINTENANCE", "BACKUP", "VERSION"), + ( + "updates", + "🔄 Обновления", + ("VERSION",), ), - SpecCategory( - key="advanced", - title="⚡ Расширенные", - description="Webhook, Web API, логирование и режим разработки.", - icon="⚡", - category_keys=("WEBHOOK", "WEB_API", "LOG", "DEBUG"), + ( + "development", + "🔧 Разработка", + ("LOG", "WEBHOOK", "WEB_API", "DEBUG"), ), ) -SPEC_CATEGORY_MAP: Dict[str, SpecCategory] = {category.key: category for category in SPEC_CATEGORIES} -CATEGORY_TO_SPEC: Dict[str, str] = {} -for category in SPEC_CATEGORIES: - for cat_key in category.category_keys: - CATEGORY_TO_SPEC[cat_key] = category.key - -MAPPED_CATEGORY_KEYS = set(CATEGORY_TO_SPEC.keys()) -DEFAULT_SPEC_KEY = SPEC_CATEGORIES[0].key -CUSTOM_PRESET_PREFIX = "botcfg_preset::" - -BUILTIN_PRESETS: Dict[str, Dict[str, Any]] = { - "recommended": { - "title": "Рекомендуемые настройки", - "description": "Сбалансированная конфигурация для стабильной работы.", - "changes": { - "SUPPORT_MENU_ENABLED": True, - "SUPPORT_SYSTEM_MODE": "both", - "SUPPORT_TICKET_SLA_ENABLED": True, - "ENABLE_NOTIFICATIONS": True, - "ADMIN_REPORTS_ENABLED": True, - "ADMIN_REPORTS_SEND_TIME": "09:00", - "REFERRAL_NOTIFICATIONS_ENABLED": True, - }, - }, - "minimal": { - "title": "Минимальная конфигурация", - "description": "Только основные функции без дополнительных уведомлений.", - "changes": { - "SUPPORT_MENU_ENABLED": False, - "ENABLE_NOTIFICATIONS": False, - "ADMIN_NOTIFICATIONS_ENABLED": False, - "ADMIN_REPORTS_ENABLED": False, - "TRIAL_DURATION_DAYS": 0, - }, - }, - "secure": { - "title": "Максимальная безопасность", - "description": "Ограничение внешнего доступа и усиленный контроль.", - "changes": { - "ENABLE_DEEP_LINKS": False, - "WEB_API_ENABLED": False, - "MAINTENANCE_AUTO_ENABLE": True, - "CONNECT_BUTTON_MODE": "guide", - "REFERRAL_NOTIFICATIONS_ENABLED": False, - }, - }, - "testing": { - "title": "Для тестирования", - "description": "Удобно для стендов и проверки интеграций.", - "changes": { - "DEBUG": True, - "ENABLE_NOTIFICATIONS": False, - "TELEGRAM_STARS_ENABLED": True, - "YOOKASSA_ENABLED": False, - "CRYPTOBOT_ENABLED": False, - "MAINTENANCE_MODE": False, - }, - }, -} - -QUICK_ACTIONS: Dict[str, Dict[str, Any]] = { - "enable_notifications": { - "title": "🟢 Включить все уведомления", - "description": "Активирует пользовательские и админские уведомления.", - "changes": { - "ENABLE_NOTIFICATIONS": True, - "ADMIN_NOTIFICATIONS_ENABLED": True, - "ADMIN_REPORTS_ENABLED": True, - "REFERRAL_NOTIFICATIONS_ENABLED": True, - }, - }, - "disable_payments": { - "title": "⚪ Отключить все платежи", - "description": "Мгновенно выключает все интеграции оплаты.", - "changes": { - "YOOKASSA_ENABLED": False, - "CRYPTOBOT_ENABLED": False, - "MULENPAY_ENABLED": False, - "PAL24_ENABLED": False, - "TRIBUTE_ENABLED": False, - "TELEGRAM_STARS_ENABLED": False, - }, - }, - "enable_maintenance": { - "title": "🔧 Включить режим обслуживания", - "description": "Переводит бота в режим техработ с текущим сообщением.", - "changes": { - "MAINTENANCE_MODE": True, - }, - }, -} - - -def _format_actor(db_user: User) -> str: - try: - full_name = db_user.full_name # type: ignore[attr-defined] - except AttributeError: - full_name = None - if not full_name: - full_name = db_user.username or f"ID{db_user.telegram_id}" - return f"{full_name}#{db_user.telegram_id}" +CATEGORY_FALLBACK_KEY = "other" +CATEGORY_FALLBACK_TITLE = "📦 Прочие настройки" async def _store_setting_context( @@ -311,312 +153,123 @@ def _chunk(buttons: Iterable[types.InlineKeyboardButton], size: int) -> Iterable yield buttons_list[index : index + size] -def _iter_all_definitions() -> Iterable[Any]: - for category_key, _, _ in bot_configuration_service.get_categories(): - for definition in bot_configuration_service.get_settings_for_category(category_key): - yield definition - - -def _preset_storage_key(slug: str) -> str: - return f"{CUSTOM_PRESET_PREFIX}{slug}" - - -async def _load_custom_presets(db: AsyncSession) -> Dict[str, Dict[str, Any]]: - result = await db.execute( - select(SystemSetting).where(SystemSetting.key.like(f"{CUSTOM_PRESET_PREFIX}%")) - ) - presets: Dict[str, Dict[str, Any]] = {} - for setting in result.scalars(): - slug = setting.key[len(CUSTOM_PRESET_PREFIX) :] - try: - payload = json.loads(setting.value or "{}") - except json.JSONDecodeError: - continue - if not isinstance(payload, dict): - continue - changes = payload.get("changes") - if not isinstance(changes, dict): - continue - presets[slug] = { - "title": payload.get("title") or f"Пользовательский ({slug})", - "description": payload.get("description") or "Сохраненный администратором пресет.", - "changes": changes, - } - return presets - - -def _parse_spec_payload(payload: str) -> Tuple[str, int]: +def _parse_category_payload(payload: str) -> Tuple[str, str, int, int]: parts = payload.split(":") - spec_key = parts[1] if len(parts) > 1 else DEFAULT_SPEC_KEY + group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY + category_key = parts[2] if len(parts) > 2 else "" + + def _safe_int(value: str, default: int = 1) -> int: + try: + return max(1, int(value)) + except (TypeError, ValueError): + return default + + category_page = _safe_int(parts[3]) if len(parts) > 3 else 1 + settings_page = _safe_int(parts[4]) if len(parts) > 4 else 1 + return group_key, category_key, category_page, settings_page + + +def _parse_group_payload(payload: str) -> Tuple[str, int]: + parts = payload.split(":") + group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY try: page = max(1, int(parts[2])) except (IndexError, ValueError): page = 1 - return spec_key, page + return group_key, page -def _get_spec_settings(spec_key: str) -> List[Any]: - category = SPEC_CATEGORY_MAP.get(spec_key) - if not category: - return [] +def _get_grouped_categories() -> List[Tuple[str, str, List[Tuple[str, str, int]]]]: + categories = bot_configuration_service.get_categories() + categories_map = {key: (label, count) for key, label, count in categories} + used: set[str] = set() + grouped: List[Tuple[str, str, List[Tuple[str, str, int]]]] = [] - definitions: List[Any] = [] - seen: set[str] = set() + for group_key, title, category_keys in CATEGORY_GROUP_DEFINITIONS: + items: List[Tuple[str, str, int]] = [] + for category_key in category_keys: + if category_key in categories_map: + label, count = categories_map[category_key] + items.append((category_key, label, count)) + used.add(category_key) + if items: + grouped.append((group_key, title, items)) - for category_key in category.category_keys: - for definition in bot_configuration_service.get_settings_for_category(category_key): - if definition.key not in seen: - definitions.append(definition) - seen.add(definition.key) - - if spec_key == "advanced": - for category_key, _, _ in bot_configuration_service.get_categories(): - if category_key not in MAPPED_CATEGORY_KEYS: - for definition in bot_configuration_service.get_settings_for_category(category_key): - if definition.key not in seen: - definitions.append(definition) - seen.add(definition.key) - - definitions.sort(key=lambda definition: definition.display_name.lower()) - return definitions - - -async def _apply_changeset( - db: AsyncSession, - changes: Dict[str, Any], - *, - db_user: User, - reason: str, -) -> Tuple[List[Tuple[str, Any]], List[Tuple[str, str]]]: - actor = _format_actor(db_user) - applied: List[Tuple[str, Any]] = [] - failed: List[Tuple[str, str]] = [] - - for key, value in changes.items(): - try: - if value is None: - await bot_configuration_service.reset_value( - db, key, actor=actor, reason=reason - ) - applied.append((key, value)) - else: - prepared_value = value - if isinstance(value, str): - try: - prepared_value = bot_configuration_service.parse_user_value( - key, value - ) - except ValueError: - prepared_value = value - await bot_configuration_service.set_value( - db, key, prepared_value, actor=actor, reason=reason - ) - applied.append((key, prepared_value)) - except Exception as error: # pragma: no cover - defensive - failed.append((key, str(error))) - - if applied: - await db.commit() - else: - await db.rollback() - - return applied, failed - - -def _get_spec_key_for_category(category_key: str) -> str: - return CATEGORY_TO_SPEC.get(category_key, "advanced") - - -def _get_spec_page_for_setting(spec_key: str, setting_key: str) -> int: - definitions = _get_spec_settings(spec_key) - for index, definition in enumerate(definitions): - if definition.key == setting_key: - return (index // SETTINGS_PAGE_SIZE) + 1 - return 1 - - -def _compute_category_health(spec_key: str) -> Tuple[str, str]: - if spec_key == "core": - if settings.CHANNEL_IS_REQUIRED_SUB and not settings.CHANNEL_LINK: - return "🟡", "Добавьте ссылку на обязательный канал" - if settings.CHANNEL_IS_REQUIRED_SUB: - return "🟢", "Обязательная подписка активна" - return "⚪", "Канал не обязателен" - - if spec_key == "support": - return ( - "🟢" if settings.SUPPORT_MENU_ENABLED else "⚪", - "Меню поддержки включено" if settings.SUPPORT_MENU_ENABLED else "Меню поддержки скрыто", - ) - - if spec_key == "payments": - active = sum( - [ - 1 if settings.is_yookassa_enabled() else 0, - 1 if settings.is_cryptobot_enabled() else 0, - 1 if settings.is_mulenpay_enabled() else 0, - 1 if settings.is_pal24_enabled() else 0, - 1 if settings.TRIBUTE_ENABLED else 0, - 1 if settings.TELEGRAM_STARS_ENABLED else 0, - ] - ) - if active: - return "🟢", f"Активных методов: {active}" - return "🔴", "Нет настроенных платежных систем" - - if spec_key == "subscriptions": - periods = [period.strip() for period in (settings.AVAILABLE_SUBSCRIPTION_PERIODS or "").split(",") if period.strip()] - base_price_ok = bool(settings.BASE_SUBSCRIPTION_PRICE) - if base_price_ok and periods: - return "🟢", f"Доступно периодов: {len(periods)}" - return "🟡", "Проверьте периоды и цены" - - if spec_key == "trial": - if settings.TRIAL_DURATION_DAYS > 0: - return "🟢", f"{settings.TRIAL_DURATION_DAYS} дн. пробного периода" - return "⚪", "Триал отключен" - - if spec_key == "referral": - if settings.REFERRAL_COMMISSION_PERCENT > 0: - return "🟢", f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}%" - return "⚪", "Комиссии не настроены" - - if spec_key == "notifications": - if settings.ENABLE_NOTIFICATIONS: - return "🟢", "Пользовательские уведомления активны" - return "⚪", "Уведомления выключены" - - if spec_key == "branding": - if settings.ENABLE_LOGO_MODE: - return "🟢", "Брендирование включено" - return "⚪", "Используется базовый интерфейс" - - if spec_key == "database": - return "🟢", f"Режим БД: {settings.DATABASE_MODE}" - - if spec_key == "remnawave": - if settings.REMNAWAVE_API_URL and settings.REMNAWAVE_API_KEY: - return "🟢", "API подключен" - return "🟡", "Проверьте URL и ключ" - - if spec_key == "servers": - if settings.SERVER_STATUS_MODE != "disabled": - return "🟢", f"Режим: {settings.SERVER_STATUS_MODE}" - return "⚪", "Мониторинг выключен" - - if spec_key == "maintenance": - if settings.MAINTENANCE_MODE: - return "🟡", "Техработы активны" - if settings.BACKUP_AUTO_ENABLED: - return "🟢", "Бэкапы включены" - return "⚪", "Автобэкапы отключены" - - if spec_key == "advanced": - if settings.DEBUG or settings.WEB_API_ENABLED: - return "🟡", "Проверьте режимы разработки" - return "🟢", "Продакшен режим" - - return "⚪", "Без статуса" - - -def _render_spec_category_text(spec_key: str, page: int, language: str) -> str: - category = SPEC_CATEGORY_MAP.get(spec_key) - if not category: - return "" - - definitions = _get_spec_settings(spec_key) - total_pages = max(1, math.ceil(len(definitions) / SETTINGS_PAGE_SIZE)) - page = max(1, min(page, total_pages)) - - start = (page - 1) * SETTINGS_PAGE_SIZE - end = start + SETTINGS_PAGE_SIZE - sliced = definitions[start:end] - - lines = [ - f"🏠 Главная → {category.icon} {category.title}", - "", - category.description, - "", - f"Страница {page}/{total_pages}", - "", + remaining = [ + (key, label, count) + for key, (label, count) in categories_map.items() + if key not in used ] - for definition in sliced: - entry = bot_configuration_service.get_setting_dashboard_entry(definition.key) - metadata = bot_configuration_service.get_metadata(definition.key) - lines.append(f"{entry['state_icon']} {entry['icon']} {entry['name']}") - lines.append(f" {entry['value']}") - if entry["has_override"]: - lines.append(" ♻️ Переопределено в БД") - if metadata.recommended is not None: - recommended_display = bot_configuration_service.format_setting_value( - definition.key, metadata.recommended - ) - if recommended_display != entry["value"]: - lines.append(f" ✨ Рекомендуемое: {recommended_display}") + if remaining: + remaining.sort(key=lambda item: item[1]) + grouped.append((CATEGORY_FALLBACK_KEY, CATEGORY_FALLBACK_TITLE, remaining)) - lines.append("") - lines.append("ℹ️ Используйте кнопки ниже для редактирования и справки.") - return "\n".join(lines) + return grouped -def _build_spec_category_keyboard( - spec_key: str, - page: int, - language: str, -) -> types.InlineKeyboardMarkup: - definitions = _get_spec_settings(spec_key) - total_pages = max(1, math.ceil(len(definitions) / SETTINGS_PAGE_SIZE)) - page = max(1, min(page, total_pages)) - - start = (page - 1) * SETTINGS_PAGE_SIZE - end = start + SETTINGS_PAGE_SIZE - sliced = definitions[start:end] - +def _build_groups_keyboard() -> types.InlineKeyboardMarkup: + grouped = _get_grouped_categories() rows: list[list[types.InlineKeyboardButton]] = [] - texts = get_texts(language) - if spec_key == "remnawave": + for group_key, title, items in grouped: + total = sum(count for _, _, count in items) rows.append( [ types.InlineKeyboardButton( - text="🔌 Проверить подключение", - callback_data=f"botcfg_test_remnawave:{spec_key}:{page}", + text=f"{title} ({total})", + callback_data=f"botcfg_group:{group_key}:1", ) ] ) - if spec_key == "payments": - def _test_button(label: str, method: str) -> types.InlineKeyboardButton: - return types.InlineKeyboardButton( - text=label, - callback_data=f"botcfg_test_payment:{method}:{spec_key}:{page}", + rows.append( + [ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data="admin_submenu_settings", ) + ] + ) - rows.extend( - [ - [_test_button(texts.t("PAYMENT_CARD_YOOKASSA", "💳 YooKassa · тест"), "yookassa")], - [_test_button(texts.t("PAYMENT_CARD_TRIBUTE", "💳 Tribute · тест"), "tribute")], - [_test_button(texts.t("PAYMENT_CARD_MULENPAY", "💳 MulenPay · тест"), "mulenpay")], - [_test_button(texts.t("PAYMENT_CARD_PAL24", "💳 PayPalych · тест"), "pal24")], - [_test_button(texts.t("PAYMENT_TELEGRAM_STARS", "⭐ Telegram Stars · тест"), "stars")], - [_test_button(texts.t("PAYMENT_CRYPTOBOT", "🪙 CryptoBot · тест"), "cryptobot")], - ] + return types.InlineKeyboardMarkup(inline_keyboard=rows) + + +def _build_categories_keyboard( + group_key: str, + group_title: str, + categories: List[Tuple[str, str, int]], + page: int = 1, +) -> types.InlineKeyboardMarkup: + total_pages = max(1, math.ceil(len(categories) / CATEGORY_PAGE_SIZE)) + page = max(1, min(page, total_pages)) + + start = (page - 1) * CATEGORY_PAGE_SIZE + end = start + CATEGORY_PAGE_SIZE + sliced = categories[start:end] + + rows: list[list[types.InlineKeyboardButton]] = [] + rows.append( + [ + types.InlineKeyboardButton( + text=f"— {group_title} —", + callback_data="botcfg_group:noop", + ) + ] + ) + + buttons: List[types.InlineKeyboardButton] = [] + for category_key, label, count in sliced: + button_text = f"{label} ({count})" + buttons.append( + types.InlineKeyboardButton( + text=button_text, + callback_data=f"botcfg_cat:{group_key}:{category_key}:{page}:1", + ) ) - for definition in sliced: - entry = bot_configuration_service.get_setting_dashboard_entry(definition.key) - callback_token = bot_configuration_service.get_callback_token(definition.key) - name_prefix = "★ " if entry["has_override"] else "" - button_text = f"{entry['icon']} {name_prefix}{entry['name']}" - info_callback = f"botcfg_info:{spec_key}:{page}:1:{callback_token}" - edit_callback = f"botcfg_setting:{spec_key}:{page}:1:{callback_token}" - rows.append( - [ - types.InlineKeyboardButton(text=button_text, callback_data=edit_callback), - types.InlineKeyboardButton(text="ℹ️", callback_data=info_callback), - ] - ) + for chunk in _chunk(buttons, 2): + rows.append(list(chunk)) if total_pages > 1: nav_row: list[types.InlineKeyboardButton] = [] @@ -624,7 +277,7 @@ def _build_spec_category_keyboard( nav_row.append( types.InlineKeyboardButton( text="⬅️", - callback_data=f"botcfg_group:{spec_key}:{page - 1}", + callback_data=f"botcfg_group:{group_key}:{page - 1}", ) ) nav_row.append( @@ -637,57 +290,135 @@ def _build_spec_category_keyboard( nav_row.append( types.InlineKeyboardButton( text="➡️", - callback_data=f"botcfg_group:{spec_key}:{page + 1}", + callback_data=f"botcfg_group:{group_key}:{page + 1}", ) ) rows.append(nav_row) rows.append( [ - types.InlineKeyboardButton(text="🔍 Поиск", callback_data="botcfg_search:start"), - types.InlineKeyboardButton(text="⚡ Быстрые действия", callback_data="botcfg_quick_menu"), - ] - ) - - rows.append( - [ - types.InlineKeyboardButton(text="⬅️ К разделам", callback_data="admin_bot_config"), - types.InlineKeyboardButton(text="🏠 Главное меню", callback_data="admin_panel"), + types.InlineKeyboardButton( + text="⬅️ К разделам", + callback_data="admin_bot_config", + ) ] ) return types.InlineKeyboardMarkup(inline_keyboard=rows) -def _build_main_keyboard() -> types.InlineKeyboardMarkup: +def _build_settings_keyboard( + category_key: str, + group_key: str, + category_page: int, + language: str, + page: int = 1, +) -> types.InlineKeyboardMarkup: + definitions = bot_configuration_service.get_settings_for_category(category_key) + total_pages = max(1, math.ceil(len(definitions) / SETTINGS_PAGE_SIZE)) + page = max(1, min(page, total_pages)) + + start = (page - 1) * SETTINGS_PAGE_SIZE + end = start + SETTINGS_PAGE_SIZE + sliced = definitions[start:end] + rows: list[list[types.InlineKeyboardButton]] = [] - category_buttons = [ - types.InlineKeyboardButton( - text=f"{category.icon} {category.title}", - callback_data=f"botcfg_group:{category.key}:1", + texts = get_texts(language) + + if category_key == "REMNAWAVE": + rows.append( + [ + types.InlineKeyboardButton( + text="🔌 Проверить подключение", + callback_data=( + f"botcfg_test_remnawave:{group_key}:{category_key}:{category_page}:{page}" + ), + ) + ] ) - for category in SPEC_CATEGORIES - ] - for chunk in _chunk(category_buttons, 2): - rows.append(list(chunk)) + test_payment_buttons: list[list[types.InlineKeyboardButton]] = [] - rows.append([types.InlineKeyboardButton(text="🔍 Найти настройку", callback_data="botcfg_search:start")]) - rows.append([types.InlineKeyboardButton(text="🎛 Пресеты", callback_data="botcfg_presets")]) - rows.append( - [ - types.InlineKeyboardButton(text="📤 Экспорт .env", callback_data="botcfg_export"), - types.InlineKeyboardButton(text="📥 Импорт .env", callback_data="botcfg_import"), - ] - ) - rows.append([types.InlineKeyboardButton(text="🕑 История изменений", callback_data="botcfg_history")]) - rows.append([types.InlineKeyboardButton(text="⚡ Быстрые действия", callback_data="botcfg_quick_menu")]) - rows.append( - [ - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings"), - types.InlineKeyboardButton(text="🏠 В главное меню", callback_data="admin_panel"), - ] - ) + def _test_button(text: str, method: str) -> types.InlineKeyboardButton: + return types.InlineKeyboardButton( + text=text, + callback_data=( + f"botcfg_test_payment:{method}:{group_key}:{category_key}:{category_page}:{page}" + ), + ) + + if category_key == "YOOKASSA": + label = texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)") + test_payment_buttons.append([_test_button(f"{label} · тест", "yookassa")]) + elif category_key == "TRIBUTE": + label = texts.t("PAYMENT_CARD_TRIBUTE", "💳 Банковская карта (Tribute)") + test_payment_buttons.append([_test_button(f"{label} · тест", "tribute")]) + elif category_key == "MULENPAY": + label = texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)") + test_payment_buttons.append([_test_button(f"{label} · тест", "mulenpay")]) + elif category_key == "PAL24": + label = texts.t("PAYMENT_CARD_PAL24", "💳 Банковская карта (PayPalych)") + test_payment_buttons.append([_test_button(f"{label} · тест", "pal24")]) + elif category_key == "TELEGRAM": + label = texts.t("PAYMENT_TELEGRAM_STARS", "⭐ Telegram Stars") + test_payment_buttons.append([_test_button(f"{label} · тест", "stars")]) + elif category_key == "CRYPTOBOT": + label = texts.t("PAYMENT_CRYPTOBOT", "🪙 Криптовалюта (CryptoBot)") + test_payment_buttons.append([_test_button(f"{label} · тест", "cryptobot")]) + + if test_payment_buttons: + rows.extend(test_payment_buttons) + + for definition in sliced: + value_preview = bot_configuration_service.format_value_for_list(definition.key) + button_text = f"{definition.display_name} · {value_preview}" + if len(button_text) > 64: + button_text = button_text[:63] + "…" + callback_token = bot_configuration_service.get_callback_token(definition.key) + rows.append( + [ + types.InlineKeyboardButton( + text=button_text, + callback_data=( + f"botcfg_setting:{group_key}:{category_page}:{page}:{callback_token}" + ), + ) + ] + ) + + if total_pages > 1: + nav_row: list[types.InlineKeyboardButton] = [] + if page > 1: + nav_row.append( + types.InlineKeyboardButton( + text="⬅️", + callback_data=( + f"botcfg_cat:{group_key}:{category_key}:{category_page}:{page - 1}" + ), + ) + ) + nav_row.append( + types.InlineKeyboardButton( + text=f"{page}/{total_pages}", callback_data="botcfg_cat_page:noop" + ) + ) + if page < total_pages: + nav_row.append( + types.InlineKeyboardButton( + text="➡️", + callback_data=( + f"botcfg_cat:{group_key}:{category_key}:{category_page}:{page + 1}" + ), + ) + ) + rows.append(nav_row) + + rows.append([ + types.InlineKeyboardButton( + text="⬅️ К категориям", + callback_data=f"botcfg_group:{group_key}:{category_page}", + ) + ]) return types.InlineKeyboardMarkup(inline_keyboard=rows) @@ -699,13 +430,12 @@ def _build_setting_keyboard( settings_page: int, ) -> types.InlineKeyboardMarkup: definition = bot_configuration_service.get_definition(key) - metadata = bot_configuration_service.get_metadata(key) rows: list[list[types.InlineKeyboardButton]] = [] callback_token = bot_configuration_service.get_callback_token(key) - current_value = bot_configuration_service.get_current_value(key) choice_options = bot_configuration_service.get_choice_options(key) if choice_options: + current_value = bot_configuration_service.get_current_value(key) choice_buttons: list[types.InlineKeyboardButton] = [] for option in choice_options: choice_token = bot_configuration_service.get_choice_token(key, option.value) @@ -727,124 +457,60 @@ def _build_setting_keyboard( rows.append(list(chunk)) if definition.python_type is bool: - toggle_text = "❌ Выключить" if bool(current_value) else "✅ Включить" - rows.append( - [ - types.InlineKeyboardButton( - text=toggle_text, - callback_data=( - f"botcfg_toggle:{group_key}:{category_page}:{settings_page}:{callback_token}" - ), - ) - ] - ) - - rows.append( - [ + rows.append([ types.InlineKeyboardButton( - text="✏️ Изменить", + text="🔁 Переключить", callback_data=( - f"botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}" + f"botcfg_toggle:{group_key}:{category_page}:{settings_page}:{callback_token}" ), ) - ] - ) + ]) - if metadata.recommended is not None: - rows.append( - [ - types.InlineKeyboardButton( - text="✨ Применить рекомендуемое", - callback_data=( - f"botcfg_recommend:{group_key}:{category_page}:{settings_page}:{callback_token}" - ), - ) - ] + rows.append([ + types.InlineKeyboardButton( + text="✏️ Изменить", + callback_data=( + f"botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}" + ), ) - - rows.append( - [ - types.InlineKeyboardButton( - text="ℹ️ Помощь", - callback_data=( - f"botcfg_info:{group_key}:{category_page}:{settings_page}:{callback_token}" - ), - ) - ] - ) + ]) if bot_configuration_service.has_override(key): - rows.append( - [ - types.InlineKeyboardButton( - text="♻️ Сбросить", - callback_data=( - f"botcfg_reset:{group_key}:{category_page}:{settings_page}:{callback_token}" - ), - ) - ] - ) - - rows.append( - [ + rows.append([ types.InlineKeyboardButton( - text="⬅️ К списку", - callback_data=f"botcfg_group:{group_key}:{category_page}", - ), - types.InlineKeyboardButton(text="🏠 Панель", callback_data="admin_bot_config"), - ] - ) - - rows.append( - [ - types.InlineKeyboardButton( - text="⚙️ Настройки", - callback_data="admin_submenu_settings", + text="♻️ Сбросить", + callback_data=( + f"botcfg_reset:{group_key}:{category_page}:{settings_page}:{callback_token}" + ), ) - ] - ) + ]) + + rows.append([ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data=( + f"botcfg_cat:{group_key}:{definition.category_key}:{category_page}:{settings_page}" + ), + ) + ]) return types.InlineKeyboardMarkup(inline_keyboard=rows) -def _render_setting_text(key: str, spec_category: SpecCategory) -> str: +def _render_setting_text(key: str) -> str: summary = bot_configuration_service.get_setting_summary(key) - metadata = bot_configuration_service.get_metadata(key) lines = [ - f"🏠 Главная → {spec_category.icon} {spec_category.title} → ⚙️ {summary['name']}", - "", + "🧩 Настройка", + f"Название: {summary['name']}", + f"Ключ: {summary['key']}", + f"Категория: {summary['category_label']}", + f"Тип: {summary['type']}", + f"Текущее значение: {summary['current']}", + f"Значение по умолчанию: {summary['original']}", + f"Переопределено в БД: {'✅ Да' if summary['has_override'] else '❌ Нет'}", ] - if metadata.description: - lines.append(f"📝 {metadata.description}") - lines.append("") - - lines.extend( - [ - f"🔑 Ключ: {summary['key']}", - f"📂 Категория: {summary['category_label']}", - f"📦 Тип: {summary['type']}", - f"📘 Текущее значение: {summary['current']}", - f"📗 По умолчанию: {summary['original']}", - f"📥 Переопределено: {'✅ Да' if summary['has_override'] else '❌ Нет'}", - ] - ) - - if metadata.format_hint: - lines.append(f"📐 Формат: {metadata.format_hint}") - if metadata.example: - lines.append(f"💡 Пример: {metadata.example}") - if metadata.warning: - lines.append(f"⚠️ Важно: {metadata.warning}") - if metadata.dependencies: - lines.append(f"🔗 Связано: {metadata.dependencies}") - if metadata.recommended is not None: - recommended_display = bot_configuration_service.format_setting_value( - key, metadata.recommended - ) - lines.append(f"✨ Рекомендуемое: {recommended_display}") - choices = bot_configuration_service.get_choice_options(key) if choices: current_raw = bot_configuration_service.get_current_value(key) @@ -852,9 +518,7 @@ def _render_setting_text(key: str, spec_category: SpecCategory) -> str: lines.append("Доступные значения:") for option in choices: marker = "✅" if current_raw == option.value else "•" - value_display = bot_configuration_service.format_setting_value( - key, option.value - ) + value_display = bot_configuration_service.format_value(option.value) description = option.description or "" if description: lines.append( @@ -872,25 +536,13 @@ async def show_bot_config_menu( callback: types.CallbackQuery, db_user: User, db: AsyncSession, - acknowledge: bool = True, ): - lines = ["⚙️ Панель управления ботом", ""] - - for category in SPEC_CATEGORIES: - status_icon, summary = _compute_category_health(category.key) - lines.append(f"{status_icon} {category.icon} {category.title} — {summary}") - - lines.append("") - lines.append("Выберите раздел или воспользуйтесь поиском ниже.") - - keyboard = _build_main_keyboard() + keyboard = _build_groups_keyboard() await callback.message.edit_text( - "\n".join(lines), + "🧩 Конфигурация бота\n\nВыберите раздел настроек:", reply_markup=keyboard, - parse_mode="HTML", ) - if acknowledge: - await callback.answer() + await callback.answer() @admin_required @@ -900,584 +552,50 @@ async def show_bot_config_group( db_user: User, db: AsyncSession, ): - spec_key, page = _parse_spec_payload(callback.data) - category = SPEC_CATEGORY_MAP.get(spec_key) - if not category: - await callback.answer("Раздел больше недоступен", show_alert=True) + group_key, page = _parse_group_payload(callback.data) + grouped = _get_grouped_categories() + group_lookup = {key: (title, items) for key, title, items in grouped} + + if group_key not in group_lookup: + await callback.answer("Эта группа больше недоступна", show_alert=True) return - text = _render_spec_category_text(spec_key, page, db_user.language) - keyboard = _build_spec_category_keyboard(spec_key, page, db_user.language) - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") - await callback.answer() - - -@admin_required -@error_handler -async def start_search_workflow( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, -): - await state.set_state(BotConfigStates.waiting_for_search_query) - await state.update_data( - botcfg_origin="bot_config_search", - search_return_payload=callback.data, - ) - - lines = [ - "🔍 Поиск по настройкам", - "", - "Введите часть названия, ключа или описания настройки.", - "Можно вводить несколько слов для уточнения.", - "Напишите cancel, чтобы выйти из поиска.", - ] - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="🏠 Панель", callback_data="admin_bot_config" - ), - types.InlineKeyboardButton( - text="❌ Отмена", callback_data="botcfg_search:cancel" - ), - ] - ] - ) - - await callback.message.answer("\n".join(lines), parse_mode="HTML", reply_markup=keyboard) - await callback.answer("Введите запрос для поиска", show_alert=True) - - -@admin_required -@error_handler -async def cancel_search( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, -): - await state.clear() - await callback.answer("Поиск отменен") - await show_bot_config_menu(callback, db_user, db, acknowledge=False) - - -@admin_required -@error_handler -async def handle_search_query( - message: types.Message, - db_user: User, - db: AsyncSession, - state: FSMContext, -): - query = (message.text or "").strip() - if not query: - await message.answer("Введите запрос для поиска настроек.") - return - - if query.lower() in {"cancel", "отмена", "стоп"}: - await message.answer( - "Поиск отменен.", - reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="🏠 Панель", callback_data="admin_bot_config")]] - ), - ) - await state.clear() - return - - keys = bot_configuration_service.search_settings(query) - if not keys: - await message.answer( - "😕 Ничего не найдено. Попробуйте уточнить запрос или используйте другое ключевое слово.", - ) - return - - lines = ["🔎 Найдены настройки", ""] - keyboard_rows: List[List[types.InlineKeyboardButton]] = [] - - for key in keys: - definition = bot_configuration_service.get_definition(key) - metadata = bot_configuration_service.get_metadata(key) - spec_key = _get_spec_key_for_category(definition.category_key) - spec_category = SPEC_CATEGORY_MAP.get(spec_key, SPEC_CATEGORY_MAP[DEFAULT_SPEC_KEY]) - page = _get_spec_page_for_setting(spec_key, key) - callback_token = bot_configuration_service.get_callback_token(key) - - summary = bot_configuration_service.get_setting_summary(key) - value_preview = summary["current"] - - lines.append( - f"{spec_category.icon} {definition.display_name}{key}\n" - f"Текущее значение: {value_preview}\n" - f"Категория: {summary['category_label']}" - ) - if metadata.description: - lines.append(_shorten_text(metadata.description)) - lines.append("") - - keyboard_rows.append( - [ - types.InlineKeyboardButton( - text=f"{spec_category.icon} {definition.display_name}", - callback_data=f"botcfg_setting:{spec_key}:{page}:1:{callback_token}", - ) - ] - ) - - keyboard_rows.append([ - types.InlineKeyboardButton(text="🔁 Новый поиск", callback_data="botcfg_search:start"), - types.InlineKeyboardButton(text="🏠 Панель", callback_data="admin_bot_config"), - ]) - - await message.answer( - "\n".join(lines).rstrip(), - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), - parse_mode="HTML", - ) - await state.update_data(botcfg_search_last_query=query) - - -def _shorten_text(text: str, limit: int = 120) -> str: - text = text.strip() - if len(text) <= limit: - return text - return text[: limit - 1] + "…" - - -@admin_required -@error_handler -async def show_quick_actions_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - lines = ["⚡ Быстрые действия", ""] - buttons: List[types.InlineKeyboardButton] = [] - - for key, action in QUICK_ACTIONS.items(): - lines.append(f"{action['title']}") - lines.append(f"— {action['description']}") - lines.append("") - buttons.append( - types.InlineKeyboardButton(text=action["title"], callback_data=f"botcfg_quick:{key}") - ) - - keyboard_rows = [list(chunk) for chunk in _chunk(buttons, 1)] - keyboard_rows.append( - [ - types.InlineKeyboardButton(text="🔁 Обновить", callback_data="botcfg_quick_menu"), - types.InlineKeyboardButton(text="🏠 Панель", callback_data="admin_bot_config"), - ] - ) - + group_title, items = group_lookup[group_key] + keyboard = _build_categories_keyboard(group_key, group_title, items, page) await callback.message.edit_text( - "\n".join(lines).rstrip(), - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), - parse_mode="HTML", + f"🧩 {group_title}\n\nВыберите категорию настроек:", + reply_markup=keyboard, ) await callback.answer() @admin_required @error_handler -async def apply_quick_action( +async def show_bot_config_category( callback: types.CallbackQuery, db_user: User, db: AsyncSession, - state: FSMContext, ): - action_key = callback.data.split(":", 1)[1] if ":" in callback.data else "" - action = QUICK_ACTIONS.get(action_key) - if not action: - await callback.answer("Действие недоступно", show_alert=True) + group_key, category_key, category_page, settings_page = _parse_category_payload( + callback.data + ) + definitions = bot_configuration_service.get_settings_for_category(category_key) + + if not definitions: + await callback.answer("В этой категории пока нет настроек", show_alert=True) return - applied, failed = await _apply_changeset( - db, - action["changes"], - db_user=db_user, - reason=f"quick:{action_key}", + category_label = definitions[0].category_label + keyboard = _build_settings_keyboard( + category_key, + group_key, + category_page, + db_user.language, + settings_page, ) - - lines = [f"{action['title']}", ""] - if applied: - lines.append(f"✅ Применено: {len(applied)} настроек") - if failed: - lines.append(f"⚠️ Ошибок: {len(failed)}") - for key, error in failed[:5]: - lines.append(f"• {key}: {error}") - lines.append("") - lines.append("Вы можете открыть панель и проверить результат.") - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [types.InlineKeyboardButton(text="🏠 Панель", callback_data="admin_bot_config")], - [types.InlineKeyboardButton(text="⚡ К действиям", callback_data="botcfg_quick_menu")], - ] - ) - await callback.message.edit_text( - "\n".join(lines).rstrip(), + f"🧩 {category_label}\n\nВыберите настройку для просмотра:", reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer("Готово") - - -@admin_required -@error_handler -async def show_presets_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - acknowledge: bool = True, -): - custom_presets = await _load_custom_presets(db) - lines = ["🎛 Пресеты настроек", ""] - - if BUILTIN_PRESETS: - lines.append("Встроенные пресеты:") - for slug, preset in BUILTIN_PRESETS.items(): - lines.append(f"• {preset['title']} — {preset['description']}") - lines.append("") - - if custom_presets: - lines.append("Пользовательские пресеты:") - for slug, preset in custom_presets.items(): - lines.append(f"• {preset['title']} — {preset['description']}") - lines.append("") - else: - lines.append("Пользовательские пресеты пока не сохранены.") - lines.append("") - - keyboard_rows: List[List[types.InlineKeyboardButton]] = [] - for slug, preset in BUILTIN_PRESETS.items(): - keyboard_rows.append( - [ - types.InlineKeyboardButton( - text=preset["title"], - callback_data=f"botcfg_preset_apply:{slug}", - ) - ] - ) - - for slug, preset in custom_presets.items(): - keyboard_rows.append( - [ - types.InlineKeyboardButton( - text=preset["title"], - callback_data=f"botcfg_preset_apply:{slug}", - ), - types.InlineKeyboardButton( - text="🗑️", callback_data=f"botcfg_preset_delete:{slug}" - ), - ] - ) - - keyboard_rows.append( - [ - types.InlineKeyboardButton( - text="💾 Сохранить текущие настройки", callback_data="botcfg_preset_save" - ) - ] - ) - keyboard_rows.append( - [ - types.InlineKeyboardButton(text="🏠 Панель", callback_data="admin_bot_config"), - types.InlineKeyboardButton(text="⚡ Быстрые", callback_data="botcfg_quick_menu"), - ] - ) - - await callback.message.edit_text( - "\n".join(lines).rstrip(), - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), - parse_mode="HTML", - ) - if acknowledge: - await callback.answer() - - -@admin_required -@error_handler -async def apply_preset( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, -): - slug = callback.data.split(":", 1)[1] if ":" in callback.data else "" - preset = BUILTIN_PRESETS.get(slug) - if not preset: - custom_presets = await _load_custom_presets(db) - preset = custom_presets.get(slug) - if not preset: - await callback.answer("Пресет не найден", show_alert=True) - return - - applied, failed = await _apply_changeset( - db, - preset["changes"], - db_user=db_user, - reason=f"preset:{slug}", - ) - - lines = [f"{preset['title']}", ""] - if applied: - lines.append(f"✅ Применено настроек: {len(applied)}") - if failed: - lines.append(f"⚠️ Ошибок: {len(failed)}") - for key, error in failed[:5]: - lines.append(f"• {key}: {error}") - if not failed: - lines.append("Все изменения успешно применены.") - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [types.InlineKeyboardButton(text="🏠 Панель", callback_data="admin_bot_config")], - [types.InlineKeyboardButton(text="🎛 К пресетам", callback_data="botcfg_presets")], - ] - ) - - await callback.message.edit_text( - "\n".join(lines).rstrip(), - reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer("Пресет применен") - - -@admin_required -@error_handler -async def start_save_preset( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, -): - await state.set_state(BotConfigStates.waiting_for_preset_name) - await state.update_data(botcfg_origin="bot_config_preset") - - await callback.message.answer( - "💾 Сохранение пресета\n\nВведите имя пресета. Используйте короткое и понятное название.", - parse_mode="HTML", - ) - await callback.answer("Введите название пресета") - - -@admin_required -@error_handler -async def handle_preset_name( - message: types.Message, - db_user: User, - db: AsyncSession, - state: FSMContext, -): - name = (message.text or "").strip() - if not name: - await message.answer("Название не может быть пустым.") - return - - slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") - if not slug: - await message.answer("Используйте латинские буквы и цифры в названии.") - return - - if slug in BUILTIN_PRESETS: - await message.answer("Это имя зарезервировано. Выберите другое название.") - return - - custom_presets = await _load_custom_presets(db) - if slug in custom_presets: - await message.answer("Пресет с таким именем уже существует. Укажите другое имя.") - return - - snapshot: Dict[str, Any] = {} - for definition in _iter_all_definitions(): - snapshot[definition.key] = bot_configuration_service.get_current_value( - definition.key - ) - - payload = { - "title": name, - "description": f"Сохранено администратором {db_user.username or db_user.telegram_id}", - "changes": snapshot, - } - - await upsert_system_setting( - db, - _preset_storage_key(slug), - json.dumps(payload, ensure_ascii=False, default=str), - ) - await db.commit() - - await message.answer( - f"✅ Пресет {name} сохранен.", - parse_mode="HTML", - reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="🎛 К пресетам", callback_data="botcfg_presets")]] - ), - ) - await state.clear() - - -@admin_required -@error_handler -async def delete_preset( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, -): - slug = callback.data.split(":", 1)[1] if ":" in callback.data else "" - key = _preset_storage_key(slug) - await delete_system_setting(db, key) - await db.commit() - - await callback.answer("Пресет удален") - await show_presets_menu(callback, db_user, db, acknowledge=False) - - -@admin_required -@error_handler -async def export_settings_env( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - dump = bot_configuration_service.generate_env_dump(include_secrets=False) - file = BufferedInputFile(dump.encode("utf-8"), filename="bot-settings.env") - caption = ( - "📤 Экспорт настроек\n" - "Секретные значения скрыты. Отправьте файл разработчику или сохраните как бэкап." - ) - await callback.message.answer_document(file, caption=caption, parse_mode="HTML") - await callback.answer("Файл сформирован") - - -@admin_required -@error_handler -async def start_import_settings( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, -): - await state.set_state(BotConfigStates.waiting_for_import_payload) - await state.update_data(botcfg_origin="bot_config_import") - - lines = [ - "📥 Импорт настроек", - "", - "Отправьте содержимое .env файла текстом или прикрепите файл.", - "Секретные значения будут применены сразу после проверки.", - "Напишите cancel для отмены.", - ] - - await callback.message.answer("\n".join(lines), parse_mode="HTML") - await callback.answer("Пришлите .env файл") - - -@admin_required -@error_handler -async def handle_import_payload( - message: types.Message, - db_user: User, - db: AsyncSession, - state: FSMContext, -): - if message.text and message.text.strip().lower() in {"cancel", "отмена"}: - await message.answer("Импорт отменен.") - await state.clear() - return - - content: Optional[str] = None - - if message.document: - buffer = io.BytesIO() - await message.document.download(destination=buffer) - try: - content = buffer.getvalue().decode("utf-8") - except UnicodeDecodeError: - await message.answer("Не удалось прочитать файл. Убедитесь, что он в UTF-8.") - return - elif message.text: - content = message.text - - if not content: - await message.answer("Пришлите текст или файл с настройками.") - return - - try: - parsed = bot_configuration_service.parse_env_dump(content) - except ValueError as error: - await message.answer(f"⚠️ Ошибка: {error}") - return - - if not parsed: - await message.answer("Файл не содержит подходящих настроек.") - return - - applied, failed = await _apply_changeset( - db, - parsed, - db_user=db_user, - reason="import", - ) - - lines = ["📥 Импорт завершен", ""] - if applied: - lines.append(f"✅ Обновлено настроек: {len(applied)}") - if failed: - lines.append(f"⚠️ Ошибок: {len(failed)}") - for key, error in failed[:5]: - lines.append(f"• {key}: {error}") - lines.append("Перейдите в панель, чтобы проверить изменения.") - - await message.answer( - "\n".join(lines), - parse_mode="HTML", - reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="🏠 Панель", callback_data="admin_bot_config")]] - ), - ) - await state.clear() - - -@admin_required -@error_handler -async def show_history( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - history = bot_configuration_service.get_history() - lines = ["🕑 История изменений", ""] - - if not history: - lines.append("История пока пуста. Измените любую настройку, чтобы увидеть журнал.") - else: - for entry in history: - timestamp: datetime = entry["timestamp"] - formatted = timestamp.strftime("%d.%m.%Y %H:%M:%S") - actor = entry.get("actor") or "system" - reason = entry.get("reason") or "manual" - lines.append( - f"• {entry['name']} ({entry['key']})\n" - f" {formatted} — {actor}\n" - f" {entry['old']} → {entry['new']} ({reason})" - ) - lines.append("") - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="🏠 Панель", callback_data="admin_bot_config")]] - ) - - await callback.message.edit_text( - "\n".join(lines).rstrip(), - reply_markup=keyboard, - parse_mode="HTML", ) await callback.answer() @@ -1489,16 +607,19 @@ async def test_remnawave_connection( db_user: User, db: AsyncSession, ): - parts = callback.data.split(":", 3) - spec_key = parts[1] if len(parts) > 1 else "remnawave" - try: - page = max(1, int(parts[2])) if len(parts) > 2 else 1 - except ValueError: - page = 1 + parts = callback.data.split(":", 5) + group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY + category_key = parts[2] if len(parts) > 2 else "REMNAWAVE" - if spec_key not in SPEC_CATEGORY_MAP: - await callback.answer("Раздел недоступен", show_alert=True) - return + try: + category_page = max(1, int(parts[3])) if len(parts) > 3 else 1 + except ValueError: + category_page = 1 + + try: + settings_page = max(1, int(parts[4])) if len(parts) > 4 else 1 + except ValueError: + settings_page = 1 service = RemnaWaveService() result = await service.test_api_connection() @@ -1518,11 +639,20 @@ async def test_remnawave_connection( else: message = f"❌ {base_message}" - try: - keyboard = _build_spec_category_keyboard(spec_key, page, db_user.language) - await callback.message.edit_reply_markup(reply_markup=keyboard) - except Exception: - pass + definitions = bot_configuration_service.get_settings_for_category(category_key) + if definitions: + keyboard = _build_settings_keyboard( + category_key, + group_key, + category_page, + db_user.language, + settings_page, + ) + try: + await callback.message.edit_reply_markup(reply_markup=keyboard) + except Exception: + # ignore inability to refresh markup, main result shown in alert + pass await callback.answer(message, show_alert=True) @@ -1534,18 +664,20 @@ async def test_payment_provider( db_user: User, db: AsyncSession, ): - parts = callback.data.split(":", 4) + parts = callback.data.split(":", 6) method = parts[1] if len(parts) > 1 else "" - spec_key = parts[2] if len(parts) > 2 else "payments" + group_key = parts[2] if len(parts) > 2 else CATEGORY_FALLBACK_KEY + category_key = parts[3] if len(parts) > 3 else "PAYMENT" try: - page = max(1, int(parts[3])) if len(parts) > 3 else 1 + category_page = max(1, int(parts[4])) if len(parts) > 4 else 1 except ValueError: - page = 1 + category_page = 1 - if spec_key not in SPEC_CATEGORY_MAP: - await callback.answer("Раздел недоступен", show_alert=True) - return + try: + settings_page = max(1, int(parts[5])) if len(parts) > 5 else 1 + except ValueError: + settings_page = 1 language = db_user.language texts = get_texts(language) @@ -1554,11 +686,19 @@ async def test_payment_provider( message_text: str async def _refresh_markup() -> None: - keyboard = _build_spec_category_keyboard(spec_key, page, language) - try: - await callback.message.edit_reply_markup(reply_markup=keyboard) - except Exception: - pass + definitions = bot_configuration_service.get_settings_for_category(category_key) + if definitions: + keyboard = _build_settings_keyboard( + category_key, + group_key, + category_page, + language, + settings_page, + ) + try: + await callback.message.edit_reply_markup(reply_markup=keyboard) + except Exception: + pass if method == "yookassa": if not settings.is_yookassa_enabled(): @@ -1913,7 +1053,7 @@ async def show_bot_config_setting( state: FSMContext, ): parts = callback.data.split(":", 4) - spec_key = parts[1] if len(parts) > 1 else DEFAULT_SPEC_KEY + group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY try: category_page = max(1, int(parts[2])) if len(parts) > 2 else 1 except ValueError: @@ -1928,103 +1068,19 @@ async def show_bot_config_setting( except KeyError: await callback.answer("Эта настройка больше недоступна", show_alert=True) return - category = SPEC_CATEGORY_MAP.get(spec_key, SPEC_CATEGORY_MAP[DEFAULT_SPEC_KEY]) - text = _render_setting_text(key, category) - keyboard = _build_setting_keyboard(key, spec_key, category_page, settings_page) - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + text = _render_setting_text(key) + keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page) + await callback.message.edit_text(text, reply_markup=keyboard) await _store_setting_context( state, key=key, - group_key=spec_key, + group_key=group_key, category_page=category_page, settings_page=settings_page, ) await callback.answer() -@admin_required -@error_handler -async def show_setting_info( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, -): - parts = callback.data.split(":", 4) - spec_key = parts[1] if len(parts) > 1 else DEFAULT_SPEC_KEY - try: - category_page = max(1, int(parts[2])) if len(parts) > 2 else 1 - except ValueError: - category_page = 1 - try: - settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1 - except ValueError: - settings_page = 1 - token = parts[4] if len(parts) > 4 else "" - try: - key = bot_configuration_service.resolve_callback_token(token) - except KeyError: - await callback.answer("Настройка больше недоступна", show_alert=True) - return - - definition = bot_configuration_service.get_definition(key) - metadata = bot_configuration_service.get_metadata(key) - summary = bot_configuration_service.get_setting_summary(key) - spec_category = SPEC_CATEGORY_MAP.get(spec_key, SPEC_CATEGORY_MAP[DEFAULT_SPEC_KEY]) - - lines = [ - f"ℹ️ Подробности: {definition.display_name}", - f"{spec_category.icon} Раздел: {spec_category.title}", - f"🔑 Ключ: {key}", - f"📦 Тип: {summary['type']}", - f"📘 Текущее: {summary['current']}", - f"📗 По умолчанию: {summary['original']}", - ] - - if metadata.description: - lines.append("") - lines.append(f"📝 {metadata.description}") - if metadata.format_hint: - lines.append(f"📐 Формат: {metadata.format_hint}") - if metadata.example: - lines.append(f"💡 Пример: {metadata.example}") - if metadata.warning: - lines.append(f"⚠️ Важно: {metadata.warning}") - if metadata.dependencies: - lines.append(f"🔗 Связано: {metadata.dependencies}") - if metadata.recommended is not None: - recommended = bot_configuration_service.format_setting_value(key, metadata.recommended) - lines.append(f"✨ Рекомендуемое: {recommended}") - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="⚙️ Открыть настройку", - callback_data=f"botcfg_setting:{spec_key}:{category_page}:{settings_page}:{token}", - ) - ], - [ - types.InlineKeyboardButton( - text="⬅️ К списку", - callback_data=f"botcfg_group:{spec_key}:{category_page}", - ), - types.InlineKeyboardButton( - text="🏠 Панель", - callback_data="admin_bot_config", - ), - ], - ] - ) - - await callback.message.edit_text( - "\n".join(lines), - reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer() - - @admin_required @error_handler async def start_edit_setting( @@ -2034,7 +1090,7 @@ async def start_edit_setting( state: FSMContext, ): parts = callback.data.split(":", 4) - spec_key = parts[1] if len(parts) > 1 else DEFAULT_SPEC_KEY + group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY try: category_page = max(1, int(parts[2])) if len(parts) > 2 else 1 except ValueError: @@ -2053,29 +1109,19 @@ async def start_edit_setting( summary = bot_configuration_service.get_setting_summary(key) texts = get_texts(db_user.language) - metadata = bot_configuration_service.get_metadata(key) - spec_category = SPEC_CATEGORY_MAP.get(spec_key, SPEC_CATEGORY_MAP[DEFAULT_SPEC_KEY]) instructions = [ - f"✏️ Редактирование: {summary['name']}", - f"Раздел: {spec_category.icon} {spec_category.title}", + "✏️ Редактирование настройки", + f"Название: {summary['name']}", f"Ключ: {summary['key']}", f"Тип: {summary['type']}", f"Текущее значение: {summary['current']}", - "", - "Отправьте новое значение сообщением.", + "\nОтправьте новое значение сообщением.", ] if definition.is_optional: instructions.append("Отправьте 'none' или оставьте пустым для сброса на значение по умолчанию.") - if metadata.format_hint: - instructions.append(f"Формат: {metadata.format_hint}") - if metadata.example: - instructions.append(f"Пример: {metadata.example}") - if metadata.warning: - instructions.append(f"Важно: {metadata.warning}") - instructions.append("Для отмены отправьте 'cancel'.") await callback.message.edit_text( @@ -2086,19 +1132,18 @@ async def start_edit_setting( types.InlineKeyboardButton( text=texts.BACK, callback_data=( - f"botcfg_setting:{spec_key}:{category_page}:{settings_page}:{token}" + f"botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}" ), ) ] ] ), - parse_mode="HTML", ) await _store_setting_context( state, key=key, - group_key=spec_key, + group_key=group_key, category_page=category_page, settings_page=settings_page, ) @@ -2116,8 +1161,7 @@ async def handle_edit_setting( ): data = await state.get_data() key = data.get("setting_key") - group_key = data.get("setting_group_key", DEFAULT_SPEC_KEY) - spec_category = SPEC_CATEGORY_MAP.get(group_key, SPEC_CATEGORY_MAP[DEFAULT_SPEC_KEY]) + group_key = data.get("setting_group_key", CATEGORY_FALLBACK_KEY) category_page = data.get("setting_category_page", 1) settings_page = data.get("setting_settings_page", 1) @@ -2135,10 +1179,10 @@ async def handle_edit_setting( await bot_configuration_service.set_value(db, key, value) await db.commit() - text = _render_setting_text(key, spec_category) + text = _render_setting_text(key) keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page) await message.answer("✅ Настройка обновлена") - await message.answer(text, reply_markup=keyboard, parse_mode="HTML") + await message.answer(text, reply_markup=keyboard) await state.clear() await _store_setting_context( state, @@ -2160,8 +1204,7 @@ async def handle_direct_setting_input( data = await state.get_data() key = data.get("setting_key") - group_key = data.get("setting_group_key", DEFAULT_SPEC_KEY) - spec_category = SPEC_CATEGORY_MAP.get(group_key, SPEC_CATEGORY_MAP[DEFAULT_SPEC_KEY]) + group_key = data.get("setting_group_key", CATEGORY_FALLBACK_KEY) category_page = int(data.get("setting_category_page", 1) or 1) settings_page = int(data.get("setting_settings_page", 1) or 1) @@ -2177,10 +1220,10 @@ async def handle_direct_setting_input( await bot_configuration_service.set_value(db, key, value) await db.commit() - text = _render_setting_text(key, spec_category) + text = _render_setting_text(key) keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page) await message.answer("✅ Настройка обновлена") - await message.answer(text, reply_markup=keyboard, parse_mode="HTML") + await message.answer(text, reply_markup=keyboard) await state.clear() await _store_setting_context( @@ -2201,8 +1244,7 @@ async def reset_setting( state: FSMContext, ): parts = callback.data.split(":", 4) - group_key = parts[1] if len(parts) > 1 else DEFAULT_SPEC_KEY - spec_category = SPEC_CATEGORY_MAP.get(group_key, SPEC_CATEGORY_MAP[DEFAULT_SPEC_KEY]) + group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY try: category_page = max(1, int(parts[2])) if len(parts) > 2 else 1 except ValueError: @@ -2220,9 +1262,9 @@ async def reset_setting( await bot_configuration_service.reset_value(db, key) await db.commit() - text = _render_setting_text(key, spec_category) + text = _render_setting_text(key) keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page) - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + await callback.message.edit_text(text, reply_markup=keyboard) await _store_setting_context( state, key=key, @@ -2242,8 +1284,7 @@ async def toggle_setting( state: FSMContext, ): parts = callback.data.split(":", 4) - group_key = parts[1] if len(parts) > 1 else DEFAULT_SPEC_KEY - spec_category = SPEC_CATEGORY_MAP.get(group_key, SPEC_CATEGORY_MAP[DEFAULT_SPEC_KEY]) + group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY try: category_page = max(1, int(parts[2])) if len(parts) > 2 else 1 except ValueError: @@ -2263,9 +1304,9 @@ async def toggle_setting( await bot_configuration_service.set_value(db, key, new_value) await db.commit() - text = _render_setting_text(key, spec_category) + text = _render_setting_text(key) keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page) - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + await callback.message.edit_text(text, reply_markup=keyboard) await _store_setting_context( state, key=key, @@ -2285,8 +1326,7 @@ async def apply_setting_choice( state: FSMContext, ): parts = callback.data.split(":", 5) - group_key = parts[1] if len(parts) > 1 else DEFAULT_SPEC_KEY - spec_category = SPEC_CATEGORY_MAP.get(group_key, SPEC_CATEGORY_MAP[DEFAULT_SPEC_KEY]) + group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY try: category_page = max(1, int(parts[2])) if len(parts) > 2 else 1 except ValueError: @@ -2313,9 +1353,9 @@ async def apply_setting_choice( await bot_configuration_service.set_value(db, key, value) await db.commit() - text = _render_setting_text(key, spec_category) + text = _render_setting_text(key) keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page) - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + await callback.message.edit_text(text, reply_markup=keyboard) await _store_setting_context( state, key=key, @@ -2336,12 +1376,8 @@ def register_handlers(dp: Dispatcher) -> None: F.data.startswith("botcfg_group:") & (~F.data.endswith(":noop")), ) dp.callback_query.register( - start_search_workflow, - F.data == "botcfg_search:start", - ) - dp.callback_query.register( - cancel_search, - F.data == "botcfg_search:cancel", + show_bot_config_category, + F.data.startswith("botcfg_cat:"), ) dp.callback_query.register( test_remnawave_connection, @@ -2355,10 +1391,6 @@ def register_handlers(dp: Dispatcher) -> None: show_bot_config_setting, F.data.startswith("botcfg_setting:"), ) - dp.callback_query.register( - show_setting_info, - F.data.startswith("botcfg_info:"), - ) dp.callback_query.register( start_edit_setting, F.data.startswith("botcfg_edit:"), @@ -2375,54 +1407,6 @@ def register_handlers(dp: Dispatcher) -> None: apply_setting_choice, F.data.startswith("botcfg_choice:"), ) - dp.callback_query.register( - show_quick_actions_menu, - F.data == "botcfg_quick_menu", - ) - dp.callback_query.register( - apply_quick_action, - F.data.startswith("botcfg_quick:"), - ) - dp.callback_query.register( - show_presets_menu, - F.data == "botcfg_presets", - ) - dp.callback_query.register( - apply_preset, - F.data.startswith("botcfg_preset_apply:"), - ) - dp.callback_query.register( - start_save_preset, - F.data == "botcfg_preset_save", - ) - dp.callback_query.register( - delete_preset, - F.data.startswith("botcfg_preset_delete:"), - ) - dp.callback_query.register( - export_settings_env, - F.data == "botcfg_export", - ) - dp.callback_query.register( - start_import_settings, - F.data == "botcfg_import", - ) - dp.callback_query.register( - show_history, - F.data == "botcfg_history", - ) - dp.message.register( - handle_search_query, - BotConfigStates.waiting_for_search_query, - ) - dp.message.register( - handle_import_payload, - BotConfigStates.waiting_for_import_payload, - ) - dp.message.register( - handle_preset_name, - BotConfigStates.waiting_for_preset_name, - ) dp.message.register( handle_direct_setting_input, StateFilter(None), diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 2348c41a..1ce9432a 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -1,12 +1,8 @@ import hashlib import json import logging -import re -from collections import deque -from dataclasses import dataclass, field -from datetime import datetime -from decimal import Decimal, InvalidOperation -from typing import Any, Dict, Iterable, List, Optional, Tuple, Type, Union, get_args, get_origin +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin from app.database.universal_migration import ensure_default_web_api_token @@ -39,23 +35,6 @@ def _truncate(value: str, max_len: int = 60) -> str: return value[: max_len - 1] + "…" -@dataclass(slots=True) -class SettingMetadata: - display_name: Optional[str] = None - description: Optional[str] = None - format_hint: Optional[str] = None - example: Optional[str] = None - warning: Optional[str] = None - dependencies: Optional[str] = None - icon: Optional[str] = None - input_type: Optional[str] = None - unit: Optional[str] = None - recommended: Optional[Any] = None - tags: Tuple[str, ...] = field(default_factory=tuple) - secret: Optional[bool] = None - category_description: Optional[str] = None - - @dataclass(slots=True) class SettingDefinition: key: str @@ -64,16 +43,10 @@ class SettingDefinition: python_type: Type[Any] type_label: str is_optional: bool - display_name_override: Optional[str] = None - icon_override: Optional[str] = None @property def display_name(self) -> str: - return self.display_name_override or _title_from_key(self.key) - - @property - def icon(self) -> str: - return self.icon_override or "⚙️" + return _title_from_key(self.key) @dataclass(slots=True) @@ -230,318 +203,6 @@ class BotConfigurationService: "DEBUG": "DEBUG", } - CATEGORY_DESCRIPTIONS: Dict[str, str] = { - "SUPPORT": "Настройки поддержки, тикетов и SLA.", - "LOCALIZATION": "Языки и тексты интерфейса.", - "MAINTENANCE": "Режим технических работ и сообщение для пользователей.", - "CHANNEL": "Обязательная подписка на канал и переходы.", - "ADMIN_NOTIFICATIONS": "Мгновенные уведомления администраторам.", - "ADMIN_REPORTS": "Регулярные отчеты и сводки.", - "TRIAL": "Параметры пробного периода.", - "PAID_SUBSCRIPTION": "Базовые лимиты платных подписок.", - "PERIODS": "Доступные периоды подписки.", - "SUBSCRIPTION_PRICES": "Цены на подписки по периодам.", - "TRAFFIC": "Лимиты и сброс трафика.", - "TRAFFIC_PACKAGES": "Пакеты дополнительного трафика и их стоимость.", - "DISCOUNTS": "Скидки и промо-настройки.", - "PAYMENT": "Общие платежные параметры.", - "TELEGRAM": "Telegram Stars и покупки в приложении.", - "CRYPTOBOT": "Оплата через CryptoBot.", - "YOOKASSA": "Интеграция YooKassa.", - "TRIBUTE": "Интеграция Tribute.", - "MULENPAY": "Интеграция MulenPay.", - "PAL24": "Интеграция PayPalych (PAL24).", - "REMNAWAVE": "Соединение с RemnaWave API.", - "REFERRAL": "Реферальная программа и бонусы.", - "AUTOPAY": "Автопродление подписок.", - "INTERFACE_BRANDING": "Логотип и брендовые элементы.", - "INTERFACE_SUBSCRIPTION": "Ссылка на подписку в интерфейсе.", - "CONNECT_BUTTON": "Действие кнопки «Подключиться».", - "HAPP": "Интеграция Happ и CryptoLink.", - "SKIP": "Опции быстрого старта и пропуска шагов.", - "ADDITIONAL": "Дополнительные конфигурации приложения.", - "MINIAPP": "Настройки мини-приложения Telegram.", - "DATABASE": "Выбор и подключение базы данных.", - "POSTGRES": "Параметры PostgreSQL.", - "SQLITE": "Путь и параметры SQLite.", - "REDIS": "Настройки кеша Redis.", - "MONITORING": "Мониторинг и хранение логов.", - "NOTIFICATIONS": "Пользовательские уведомления и SLA.", - "SERVER": "Проверка статуса серверов.", - "BACKUP": "Резервное копирование.", - "VERSION": "Проверка обновлений.", - "LOG": "Логирование и файлы.", - "WEBHOOK": "Webhook Telegram.", - "WEB_API": "Встроенный Web API.", - "DEBUG": "Режимы разработки и отладки.", - } - - METADATA_KEY_OVERRIDES: Dict[str, SettingMetadata] = { - "MAINTENANCE_MODE": SettingMetadata( - display_name="Режим обслуживания", - description="Переводит бота в режим технических работ и скрывает основные функции.", - format_hint="Используйте кнопки включения/выключения или ответьте 'вкл/выкл'.", - example="вкл", - warning="Пользователи не смогут использовать бота, пока режим активен.", - dependencies="MAINTENANCE_MESSAGE", - icon="🔧", - input_type="toggle", - recommended=False, - ), - "MAINTENANCE_MESSAGE": SettingMetadata( - description="Текст, который увидит пользователь во время технических работ.", - format_hint="Обычный текст, поддерживается базовое форматирование Telegram.", - example="🔧 Ведутся технические работы...", - dependencies="MAINTENANCE_MODE", - icon="💬", - ), - "DEBUG": SettingMetadata( - display_name="Режим отладки", - description="Включает подробные логи для разработчиков.", - warning="Не держите включенным в продакшене — возможна утечка чувствительных данных.", - icon="🐞", - input_type="toggle", - recommended=False, - ), - "ENABLE_NOTIFICATIONS": SettingMetadata( - description="Включает отправку уведомлений пользователям о подписках, триалах и лимитах.", - dependencies="NOTIFICATION_RETRY_ATTEMPTS, NOTIFICATION_CACHE_HOURS", - icon="🔔", - input_type="toggle", - recommended=True, - ), - "ADMIN_NOTIFICATIONS_ENABLED": SettingMetadata( - description="Рассылка мгновенных уведомлений администраторам о важных событиях.", - dependencies="ADMIN_NOTIFICATIONS_CHAT_ID", - icon="📣", - input_type="toggle", - ), - "ADMIN_REPORTS_SEND_TIME": SettingMetadata( - description="Время отправки ежедневных отчетов администраторам.", - format_hint="Введите время в формате ЧЧ:ММ.", - example="09:00", - input_type="time", - icon="🕒", - ), - "AVAILABLE_SUBSCRIPTION_PERIODS": SettingMetadata( - description="Список доступных периодов подписки в днях.", - format_hint="Перечислите значения через запятую.", - example="30,90,180", - input_type="list", - icon="📅", - ), - "BASE_SUBSCRIPTION_PRICE": SettingMetadata( - description="Базовая стоимость подписки в копейках.", - format_hint="Введите цену в рублях, например 99 000.", - example="99 000", - input_type="price", - unit="₽", - icon="💰", - ), - "TRIAL_DURATION_DAYS": SettingMetadata( - description="Количество дней пробного периода.", - example="3", - unit="дней", - icon="🎁", - recommended=3, - ), - "YOOKASSA_ENABLED": SettingMetadata( - description="Включает прием платежей через YooKassa.", - warning="Не активируйте без действующих ключей магазина.", - dependencies="YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY", - icon="💸", - input_type="toggle", - ), - "CRYPTOBOT_ENABLED": SettingMetadata( - description="Разрешает оплату через CryptoBot.", - dependencies="CRYPTOBOT_API_TOKEN", - icon="🪙", - input_type="toggle", - ), - "REMNAWAVE_API_URL": SettingMetadata( - description="Базовый URL панели RemnaWave.", - format_hint="Полный адрес, например https://panel.remnawave.com", - example="https://panel.remnawave.com", - icon="🌐", - ), - "DATABASE_MODE": SettingMetadata( - description="Выбор источника данных: auto, sqlite или postgresql.", - format_hint="Введите одно из значений auto/sqlite/postgresql.", - example="postgresql", - icon="💾", - ), - "REFERRAL_COMMISSION_PERCENT": SettingMetadata( - description="Процент комиссии для пригласившего пользователя.", - unit="%", - example="25", - icon="👥", - ), - "BACKUP_TIME": SettingMetadata( - description="Время запуска автоматического бэкапа.", - format_hint="ЧЧ:ММ, 24-часовой формат.", - example="03:00", - input_type="time", - icon="💾", - ), - "WEB_API_ENABLED": SettingMetadata( - description="Включает встроенный Web API для интеграций.", - warning="Убедитесь, что токены доступа настроены и хранятся безопасно.", - icon="🌐", - input_type="toggle", - ), - "ENABLE_DEEP_LINKS": SettingMetadata( - description="Позволяет открывать бота через глубокие ссылки.", - warning="Отключение сделает недоступными промо-ссылки и мини-приложение.", - icon="🔗", - input_type="toggle", - ), - } - - METADATA_PREFIX_HINTS: Tuple[Tuple[str, SettingMetadata], ...] = ( - ( - "PRICE_", - SettingMetadata( - icon="💰", - input_type="price", - format_hint="Введите цену в рублях, разделяя тысячи пробелами.", - example="9 990", - unit="₽", - ), - ), - ( - "YOOKASSA_", - SettingMetadata( - icon="💸", - category_description="Настройка платежей через YooKassa.", - ), - ), - ( - "CRYPTOBOT_", - SettingMetadata(icon="🪙", category_description="Интеграция с CryptoBot."), - ), - ( - "MULENPAY_", - SettingMetadata(icon="💳", category_description="Интеграция MulenPay."), - ), - ( - "PAL24_", - SettingMetadata(icon="🏦", category_description="Интеграция PayPalych (PAL24)."), - ), - ( - "TRIBUTE_", - SettingMetadata(icon="🎁", category_description="Настройки Tribute."), - ), - ( - "TELEGRAM_STARS", - SettingMetadata(icon="⭐", category_description="Платежи через Telegram Stars."), - ), - ( - "TRIAL_", - SettingMetadata(icon="🎁", category_description="Параметры пробного периода."), - ), - ( - "REFERRAL_", - SettingMetadata(icon="👥", category_description="Реферальная программа."), - ), - ( - "BACKUP_", - SettingMetadata(icon="💾", category_description="Автоматические резервные копии."), - ), - ) - - METADATA_SUFFIX_HINTS: Tuple[Tuple[str, SettingMetadata], ...] = ( - ( - "_ENABLED", - SettingMetadata( - input_type="toggle", - format_hint="Используйте кнопки или отправьте 'вкл'/'выкл'.", - example="вкл", - ), - ), - ( - "_IDS", - SettingMetadata( - input_type="list", - format_hint="Перечислите значения через запятую.", - example="123456789,987654321", - ), - ), - ( - "_PERCENT", - SettingMetadata( - input_type="number", - unit="%", - format_hint="Введите целое число от 0 до 100.", - example="25", - ), - ), - ( - "_KOPEKS", - SettingMetadata( - input_type="price", - unit="₽", - format_hint="Введите цену в рублях — бот сконвертирует в копейки.", - example="500", - ), - ), - ( - "_HOURS", - SettingMetadata( - unit="часов", - input_type="number", - example="24", - ), - ), - ( - "_MINUTES", - SettingMetadata( - unit="минут", - input_type="number", - example="15", - ), - ), - ( - "_SECONDS", - SettingMetadata( - unit="секунд", - input_type="number", - example="60", - ), - ), - ( - "_DAYS", - SettingMetadata( - unit="дней", - input_type="number", - example="30", - ), - ), - ( - "_TIME", - SettingMetadata( - input_type="time", - format_hint="Введите время в формате ЧЧ:ММ.", - example="12:30", - ), - ), - ( - "_URL", - SettingMetadata( - input_type="text", - format_hint="Полный URL, начинающийся с http или https.", - example="https://example.com", - ), - ), - ) - - SECRET_KEY_PATTERNS: Tuple[str, ...] = ( - "SECRET", - "TOKEN", - "PASSWORD", - "API_KEY", - "PRIVATE_KEY", - ) - CHOICES: Dict[str, List[ChoiceOption]] = { "DATABASE_MODE": [ ChoiceOption("auto", "🤖 Авто"), @@ -636,8 +297,6 @@ class BotConfigurationService: _token_to_key: Dict[str, str] = {} _choice_tokens: Dict[str, Dict[Any, str]] = {} _choice_token_lookup: Dict[str, Dict[str, Any]] = {} - _metadata_cache: Dict[str, SettingMetadata] = {} - _history: deque[Dict[str, Any]] = deque(maxlen=10) @classmethod def initialize_definitions(cls) -> None: @@ -658,7 +317,7 @@ class BotConfigurationService: category_key.capitalize() if category_key else "Прочее", ) - definition = SettingDefinition( + cls._definitions[key] = SettingDefinition( key=key, category_key=category_key or "other", category_label=category_label, @@ -667,349 +326,12 @@ class BotConfigurationService: is_optional=is_optional, ) - metadata = cls._build_metadata(definition) - if metadata.display_name: - definition.display_name_override = metadata.display_name - if metadata.icon: - definition.icon_override = metadata.icon - - cls._definitions[key] = definition - cls._metadata_cache[key] = metadata - cls._register_callback_token(key) if key in cls.CHOICES: cls._ensure_choice_tokens(key) @classmethod - def _build_metadata(cls, definition: SettingDefinition) -> SettingMetadata: - key = definition.key - base_metadata = SettingMetadata( - icon=cls._extract_category_icon(definition.category_label), - category_description=cls.CATEGORY_DESCRIPTIONS.get(definition.category_key), - ) - - metadata = cls._merge_metadata(base_metadata, cls._metadata_for_python_type(definition)) - - for prefix, hint in cls.METADATA_PREFIX_HINTS: - if key.startswith(prefix): - metadata = cls._merge_metadata(metadata, hint) - - for suffix, hint in cls.METADATA_SUFFIX_HINTS: - if key.endswith(suffix): - metadata = cls._merge_metadata(metadata, hint) - - key_override = cls.METADATA_KEY_OVERRIDES.get(key) - if key_override: - metadata = cls._merge_metadata(metadata, key_override) - - if metadata.display_name is None: - metadata.display_name = cls._guess_display_name(key) - - if metadata.description is None: - metadata.description = cls._default_description(definition) - - if metadata.input_type is None: - metadata.input_type = cls._default_input_type(definition) - - if metadata.format_hint is None: - metadata.format_hint = cls._default_format_hint(metadata) - - if metadata.example is None: - metadata.example = cls._default_example(metadata) - - if metadata.secret is None and cls._is_secret_key(key): - metadata.secret = True - - return metadata - - @classmethod - def _metadata_for_python_type(cls, definition: SettingDefinition) -> SettingMetadata: - python_type = definition.python_type - if python_type is bool: - return SettingMetadata( - input_type="toggle", - format_hint="Используйте кнопки включения/выключения или ответьте 'вкл'/'выкл'.", - example="вкл", - ) - if python_type is int: - return SettingMetadata( - input_type="number", - format_hint="Введите целое число.", - example="10", - ) - if python_type is float: - return SettingMetadata( - input_type="number", - format_hint="Введите число, можно использовать запятую.", - example="1,5", - ) - return SettingMetadata( - input_type="text", - format_hint="Введите текстовое значение.", - example="Пример", - ) - - @staticmethod - def _merge_metadata(base: SettingMetadata, override: SettingMetadata) -> SettingMetadata: - if override is base: - return base - - merged = SettingMetadata( - display_name=override.display_name or base.display_name, - description=override.description or base.description, - format_hint=override.format_hint or base.format_hint, - example=override.example or base.example, - warning=override.warning or base.warning, - dependencies=override.dependencies or base.dependencies, - icon=override.icon or base.icon, - input_type=override.input_type or base.input_type, - unit=override.unit or base.unit, - recommended=override.recommended if override.recommended is not None else base.recommended, - secret=override.secret if override.secret is not None else base.secret, - category_description=override.category_description or base.category_description, - ) - - if base.tags or override.tags: - tags: List[str] = list(base.tags) - for tag in override.tags: - if tag not in tags: - tags.append(tag) - merged.tags = tuple(tags) - - return merged - - @staticmethod - def _extract_category_icon(category_label: str) -> Optional[str]: - if not category_label: - return None - stripped = category_label.strip() - if not stripped: - return None - first_char = stripped[0] - if first_char.isascii(): - return None - return first_char - - @staticmethod - def _guess_display_name(key: str) -> Optional[str]: - if key.endswith("_ENABLED"): - base = key[:-8] - return _title_from_key(base) - if key.endswith("_URL"): - base = key[:-4] - return f"{_title_from_key(base)} URL" - if key.endswith("_ID"): - base = key[:-3] - return f"{_title_from_key(base)} ID" - if key.endswith("_TIME"): - base = key[:-5] - return f"{_title_from_key(base)} Время" - return _title_from_key(key) - - @staticmethod - def _default_description(definition: SettingDefinition) -> str: - return ( - f"Настройка «{definition.display_name}» в категории " - f"{definition.category_label}." - ) - - @staticmethod - def _default_input_type(definition: SettingDefinition) -> str: - if definition.python_type is bool: - return "toggle" - if definition.python_type in {int, float}: - return "number" - return "text" - - @staticmethod - def _default_format_hint(metadata: SettingMetadata) -> str: - mapping = { - "toggle": "Используйте кнопки включения/выключения.", - "number": "Введите числовое значение.", - "price": "Введите сумму в рублях.", - "time": "Введите время в формате ЧЧ:ММ.", - "list": "Перечислите значения через запятую.", - "text": "Введите текстовое значение.", - } - return mapping.get(metadata.input_type or "text", "Введите значение и отправьте сообщением.") - - @staticmethod - def _default_example(metadata: SettingMetadata) -> str: - mapping = { - "toggle": "вкл", - "number": "10", - "price": "9 990", - "time": "12:00", - "list": "значение1, значение2", - "text": "пример", - } - return mapping.get(metadata.input_type or "text", "пример") - - @classmethod - def get_metadata(cls, key: str) -> SettingMetadata: - cls.initialize_definitions() - metadata = cls._metadata_cache.get(key) - if metadata is None: - definition = cls._definitions[key] - metadata = cls._build_metadata(definition) - cls._metadata_cache[key] = metadata - return metadata - - @classmethod - def _is_secret_key(cls, key: str) -> bool: - upper = key.upper() - return any(pattern in upper for pattern in cls.SECRET_KEY_PATTERNS) - - @staticmethod - def _mask_secret(value: Any) -> str: - text = str(value or "") - if not text: - return "—" - if len(text) <= 4: - return "••••" - return "••••••••" + text[-4:] - - @staticmethod - def _format_rubles(raw_value: Any) -> str: - try: - dec_value = Decimal(str(raw_value)) - except InvalidOperation: - return str(raw_value) - - rubles = dec_value / Decimal(100) - quantized = rubles.quantize(Decimal("0.01")) - - if quantized == quantized.to_integral_value(): - integer = int(quantized) - formatted = f"{integer:,}".replace(",", " ") - else: - formatted = f"{quantized:.2f}".replace(",", " ") - - return f"{formatted} ₽" - - @staticmethod - def _parse_time(text: str) -> str: - if not re.fullmatch(r"\d{1,2}:\d{2}", text): - raise ValueError("Используйте формат ЧЧ:ММ") - hours_str, minutes_str = text.split(":", 1) - hours = int(hours_str) - minutes = int(minutes_str) - if hours < 0 or hours > 23 or minutes < 0 or minutes > 59: - raise ValueError("Часы должны быть 0-23, минуты 0-59") - return f"{hours:02d}:{minutes:02d}" - - @staticmethod - def _parse_price(text: str) -> int: - normalized = text.replace(" ", "").replace("₽", "").replace(",", ".") - if not normalized: - raise ValueError("Введите сумму в рублях") - try: - value = Decimal(normalized) - except InvalidOperation as error: - raise ValueError("Некорректное значение цены") from error - if value < 0: - raise ValueError("Цена не может быть отрицательной") - kopeks = (value * 100).quantize(Decimal("1")) - return int(kopeks) - - @staticmethod - def _parse_list(text: str) -> str: - if not text: - return "" - normalized = text.replace("\n", ",") - items = [item.strip() for item in normalized.split(",") if item.strip()] - return ",".join(items) - - @classmethod - def format_setting_value( - cls, - key: str, - value: Any, - *, - include_unit: bool = True, - mask_secrets: bool = True, - ) -> str: - metadata = cls.get_metadata(key) - definition = cls.get_definition(key) - - if value is None or value == "": - return "—" - - if mask_secrets and (metadata.secret or cls._is_secret_key(key)): - return cls._mask_secret(value) - - input_type = metadata.input_type or cls._default_input_type(definition) - unit = metadata.unit if include_unit else None - - if input_type == "toggle": - return "ВКЛЮЧЕНО" if bool(value) else "ВЫКЛЮЧЕНО" - - if input_type == "price": - return cls._format_rubles(value) - - if input_type == "list": - if isinstance(value, str): - items = [item.strip() for item in value.split(",") if item.strip()] - elif isinstance(value, Iterable) and not isinstance(value, (str, bytes)): - items = [str(item).strip() for item in value] - else: - items = [str(value)] - if not items: - return "—" - return " • ".join(items) - - if input_type == "time": - return str(value) - - if input_type == "number": - try: - number = Decimal(str(value)) - if number == number.to_integral_value(): - rendered = f"{int(number)}" - else: - rendered = str(number).replace(".", ",") - except InvalidOperation: - rendered = str(value) - if unit: - return f"{rendered} {unit}" - return rendered - - if unit: - return f"{value} {unit}" - - return str(value) - - @classmethod - def get_state_icon(cls, key: str, value: Any) -> str: - metadata = cls.get_metadata(key) - definition = cls.get_definition(key) - input_type = metadata.input_type or cls._default_input_type(definition) - - if input_type == "toggle": - return "✅" if bool(value) else "❌" - if value in (None, "", [], {}): - return "⚪" - return "🟢" - - @classmethod - def get_setting_dashboard_entry(cls, key: str) -> Dict[str, Any]: - definition = cls.get_definition(key) - metadata = cls.get_metadata(key) - current = cls.get_current_value(key) - return { - "key": key, - "name": definition.display_name, - "icon": metadata.icon or definition.icon, - "state_icon": cls.get_state_icon(key, current), - "value": cls.format_setting_value(key, current), - "has_override": cls.has_override(key), - "description": metadata.description or cls._default_description(definition), - "recommended": metadata.recommended, - "unit": metadata.unit, - "category_description": metadata.category_description, - } - def _resolve_category_key(cls, key: str) -> str: override = cls.CATEGORY_KEY_OVERRIDES.get(key) if override: @@ -1121,7 +443,7 @@ class BotConfigurationService: @classmethod def format_value_for_list(cls, key: str) -> str: value = cls.get_current_value(key) - formatted = cls.format_setting_value(key, value) + formatted = cls.format_value(value) if formatted == "—": return formatted return _truncate(formatted) @@ -1282,12 +604,9 @@ class BotConfigurationService: if definition.is_optional and text.lower() in {"none", "null", "пусто", ""}: return None - metadata = cls.get_metadata(key) - input_type = metadata.input_type or cls._default_input_type(definition) - python_type = definition.python_type - if input_type == "toggle" or python_type is bool: + if python_type is bool: lowered = text.lower() if lowered in {"1", "true", "on", "yes", "да", "вкл", "enable", "enabled"}: return True @@ -1295,14 +614,8 @@ class BotConfigurationService: return False raise ValueError("Введите 'true' или 'false' (или 'да'/'нет')") - if input_type == "price": - parsed_value = cls._parse_price(text) - elif input_type == "time": - parsed_value = cls._parse_time(text) - elif input_type == "list": - parsed_value = cls._parse_list(text) - elif python_type is int: - parsed_value = int(text) + if python_type is int: + parsed_value: Any = int(text) elif python_type is float: parsed_value = float(text.replace(",", ".")) else: @@ -1332,57 +645,22 @@ class BotConfigurationService: return parsed_value @classmethod - async def set_value( - cls, - db: AsyncSession, - key: str, - value: Any, - *, - actor: Optional[str] = None, - reason: str = "manual", - ) -> None: - old_value = cls.get_current_value(key) + async def set_value(cls, db: AsyncSession, key: str, value: Any) -> None: raw_value = cls.serialize_value(key, value) await upsert_system_setting(db, key, raw_value) cls._overrides_raw[key] = raw_value cls._apply_to_settings(key, value) - cls._record_history(key, old_value, value, actor=actor, reason=reason) - logger.info( - "Настройка %s обновлена: %s → %s (%s)", - key, - cls.format_setting_value(key, old_value), - cls.format_setting_value(key, value), - actor or "system", - ) - if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}: await cls._sync_default_web_api_token() @classmethod - async def reset_value( - cls, - db: AsyncSession, - key: str, - *, - actor: Optional[str] = None, - reason: str = "reset", - ) -> None: - old_value = cls.get_current_value(key) + async def reset_value(cls, db: AsyncSession, key: str) -> None: await delete_system_setting(db, key) cls._overrides_raw.pop(key, None) original = cls.get_original_value(key) cls._apply_to_settings(key, original) - cls._record_history(key, old_value, original, actor=actor, reason=reason) - logger.info( - "Настройка %s сброшена: %s → %s (%s)", - key, - cls.format_setting_value(key, old_value), - cls.format_setting_value(key, original), - actor or "system", - ) - if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}: await cls._sync_default_web_api_token() @@ -1415,122 +693,14 @@ class BotConfigurationService: return { "key": key, "name": definition.display_name, - "current": cls.format_setting_value(key, current), - "original": cls.format_setting_value(key, original), + "current": cls.format_value(current), + "original": cls.format_value(original), "type": definition.type_label, "category_key": definition.category_key, "category_label": definition.category_label, "has_override": has_override, } - @classmethod - def _record_history( - cls, - key: str, - old_value: Any, - new_value: Any, - *, - actor: Optional[str], - reason: str, - ) -> None: - definition = cls.get_definition(key) - entry = { - "timestamp": datetime.utcnow(), - "key": key, - "name": definition.display_name, - "old": cls.format_setting_value(key, old_value), - "new": cls.format_setting_value(key, new_value), - "actor": actor, - "reason": reason, - } - cls._history.appendleft(entry) - - @classmethod - def get_history(cls) -> List[Dict[str, Any]]: - return list(cls._history) - - @classmethod - def generate_env_dump(cls, *, include_secrets: bool = True) -> str: - cls.initialize_definitions() - lines: List[str] = [] - for key in sorted(cls._definitions.keys()): - value = cls.get_current_value(key) - raw = cls.serialize_value(key, value) - if raw is None: - continue - if not include_secrets and cls._is_secret_key(key): - lines.append(f"{key}=") - else: - escaped = raw.replace("\\", "\\\\").replace("\n", "\\n") - lines.append(f"{key}={escaped}") - return "\n".join(lines) + "\n" - - @classmethod - def parse_env_dump(cls, content: str) -> Dict[str, Any]: - cls.initialize_definitions() - result: Dict[str, Any] = {} - for raw_line in content.splitlines(): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - if "=" not in line: - continue - key, raw_value = line.split("=", 1) - key = key.strip() - if key not in cls._definitions: - continue - value_text = raw_value.strip().strip('"').strip("'") - value_text = value_text.replace("\\n", "\n") - try: - parsed_value = cls.parse_user_value(key, value_text) - except ValueError as error: - raise ValueError(f"{key}: {error}") from error - result[key] = parsed_value - return result - - @classmethod - def search_settings(cls, query: str, limit: int = 12) -> List[str]: - cls.initialize_definitions() - normalized = (query or "").strip().lower() - if not normalized: - return [] - - tokens = [token for token in re.split(r"\s+", normalized) if token] - if not tokens: - return [] - - scored: List[Tuple[float, str]] = [] - for key, definition in cls._definitions.items(): - if key in cls.EXCLUDED_KEYS: - continue - - metadata = cls.get_metadata(key) - haystacks: List[str] = [ - definition.display_name.lower(), - definition.category_label.lower(), - key.lower(), - ] - if metadata.description: - haystacks.append(metadata.description.lower()) - if metadata.tags: - haystacks.extend(tag.lower() for tag in metadata.tags) - - score = 0.0 - for token in tokens: - for haystack in haystacks: - if token == haystack: - score += 5.0 - elif token in haystack: - score += 1.0 + (len(token) / max(len(haystack), 1)) - - if score > 0: - if definition.category_key.startswith("PAYMENT"): - score += 0.1 - scored.append((score, key)) - - scored.sort(key=lambda item: (-item[0], cls._definitions[item[1]].display_name.lower())) - return [key for _, key in scored[:limit]] - bot_configuration_service = BotConfigurationService diff --git a/app/states.py b/app/states.py index 96198dc7..43655b35 100644 --- a/app/states.py +++ b/app/states.py @@ -132,9 +132,6 @@ class SupportSettingsStates(StatesGroup): class BotConfigStates(StatesGroup): waiting_for_value = State() - waiting_for_search_query = State() - waiting_for_import_payload = State() - waiting_for_preset_name = State() class AutoPayStates(StatesGroup): setting_autopay_days = State()