diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py index b129ea68..915fad3c 100644 --- a/app/handlers/admin/bot_configuration.py +++ b/app/handlers/admin/bot_configuration.py @@ -1,8 +1,15 @@ import math +import html +import io import time -from typing import Iterable, List, Tuple +from collections import deque +from dataclasses import dataclass, replace +from datetime import datetime +from enum import Enum +from typing import Any, Dict, Iterable, List, Optional, Tuple from aiogram import Dispatcher, F, types +from aiogram.types import BufferedInputFile from aiogram.filters import BaseFilter, StateFilter from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -13,7 +20,10 @@ from app.config import settings from app.services.remnawave_service import RemnaWaveService from app.services.payment_service import PaymentService from app.services.tribute_service import TributeService -from app.services.system_settings_service import bot_configuration_service +from app.services.system_settings_service import ( + SettingDefinition, + bot_configuration_service, +) from app.states import BotConfigStates from app.utils.decorators import admin_required, error_handler from app.utils.currency_converter import currency_converter @@ -24,78 +34,455 @@ CATEGORY_PAGE_SIZE = 10 SETTINGS_PAGE_SIZE = 8 -CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = ( - ( - "core", - "⚙️ Основные настройки", - ("SUPPORT", "LOCALIZATION", "MAINTENANCE"), +@dataclass(slots=True) +class MenuCategoryDefinition: + key: str + title: str + description: str + category_keys: Tuple[str, ...] + + @property + def icon(self) -> str: + return self.title.split(" ", 1)[0] + + @property + def plain_title(self) -> str: + return self.title.split(" ", 1)[1] if " " in self.title else self.title + + +class SettingKind(Enum): + TOGGLE = "toggle" + TEXT = "text" + NUMBER = "number" + FLOAT = "float" + PRICE = "price" + LIST = "list" + CHOICE = "choice" + TIME = "time" + URL = "url" + SECRET = "secret" + + +@dataclass(slots=True) +class SettingMetadata: + description: str = "" + format_hint: str = "" + example: str = "" + warning: str = "" + dependencies: str = "" + recommended: Optional[str] = None + sensitive: bool = False + unit: Optional[str] = None + doc_link: Optional[str] = None + highlight: Optional[str] = None + + +MENU_CATEGORIES: Tuple[MenuCategoryDefinition, ...] = ( + MenuCategoryDefinition( + key="core", + title="🤖 Основные", + description="Базовые параметры и ключевые переключатели", + category_keys=("CHANNEL", "SKIP", "CONNECT_BUTTON"), ), - ( - "channels_notifications", - "📢 Каналы и уведомления", - ("CHANNEL", "ADMIN_NOTIFICATIONS", "ADMIN_REPORTS"), + MenuCategoryDefinition( + key="support", + title="💬 Поддержка", + description="Тикеты, контакт и SLA", + category_keys=("SUPPORT",), ), - ( - "subscriptions", - "💎 Подписки и тарифы", - ("TRIAL", "PAID_SUBSCRIPTION", "PERIODS", "SUBSCRIPTION_PRICES", "TRAFFIC", "TRAFFIC_PACKAGES", "DISCOUNTS"), + MenuCategoryDefinition( + key="payments", + title="💳 Платежные системы", + description="YooKassa, CryptoBot, MulenPay и другие провайдеры", + category_keys=( + "PAYMENT", + "YOOKASSA", + "CRYPTOBOT", + "MULENPAY", + "PAL24", + "TRIBUTE", + "TELEGRAM", + ), ), - ( - "payments", - "💳 Платежные системы", - ("PAYMENT", "TELEGRAM", "CRYPTOBOT", "YOOKASSA", "TRIBUTE", "MULENPAY", "PAL24"), + MenuCategoryDefinition( + key="subscriptions", + title="📅 Подписки и цены", + description="Тарифы, периоды, трафик и автопродления", + category_keys=( + "PAID_SUBSCRIPTION", + "PERIODS", + "SUBSCRIPTION_PRICES", + "TRAFFIC", + "TRAFFIC_PACKAGES", + "DISCOUNTS", + "AUTOPAY", + ), ), - ( - "remnawave", - "🔗 RemnaWave API", - ("REMNAWAVE",), + MenuCategoryDefinition( + key="trial", + title="🎁 Пробный период", + description="Настройки бесплатного доступа", + category_keys=("TRIAL",), ), - ( - "referral", - "🤝 Реферальная система", - ("REFERRAL",), + MenuCategoryDefinition( + key="referral", + title="👥 Реферальная программа", + description="Бонусы, комиссии и уведомления", + category_keys=("REFERRAL",), ), - ( - "autopay", - "🔄 Автопродление", - ("AUTOPAY",), + MenuCategoryDefinition( + key="notifications", + title="🔔 Уведомления", + description="Админ-уведомления, отчеты и SLA", + category_keys=("ADMIN_NOTIFICATIONS", "ADMIN_REPORTS", "NOTIFICATIONS"), ), - ( - "interface", - "🎨 Интерфейс и UX", - ("INTERFACE_BRANDING", "INTERFACE_SUBSCRIPTION", "CONNECT_BUTTON", "HAPP", "SKIP", "ADDITIONAL"), + MenuCategoryDefinition( + key="interface", + title="🎨 Интерфейс и брендинг", + description="Логотип, тексты, языки и miniapp", + category_keys=( + "INTERFACE_BRANDING", + "INTERFACE_SUBSCRIPTION", + "HAPP", + "MINIAPP", + "LOCALIZATION", + ), ), - ( - "database", - "🗄️ База данных", - ("DATABASE", "POSTGRES", "SQLITE", "REDIS"), + MenuCategoryDefinition( + key="database", + title="💾 База данных", + description="Настройки PostgreSQL и SQLite", + category_keys=("DATABASE", "POSTGRES", "SQLITE"), ), - ( - "monitoring", - "📊 Мониторинг", - ("MONITORING", "NOTIFICATIONS", "SERVER"), + MenuCategoryDefinition( + key="remnawave", + title="🌐 RemnaWave API", + description="Интеграция с панелью VPN", + category_keys=("REMNAWAVE",), ), - ( - "backup", - "💾 Система бэкапов", - ("BACKUP",), + MenuCategoryDefinition( + key="server_status", + title="📊 Статус серверов", + description="Мониторинг, метрики и XRay", + category_keys=("SERVER", "MONITORING"), ), - ( - "updates", - "🔄 Обновления", - ("VERSION",), + MenuCategoryDefinition( + key="maintenance", + title="🔧 Обслуживание", + description="Режим ТО, бэкапы и версии", + category_keys=("MAINTENANCE", "BACKUP", "VERSION"), ), - ( - "development", - "🔧 Разработка", - ("LOG", "WEBHOOK", "WEB_API", "DEBUG"), + MenuCategoryDefinition( + key="advanced", + title="⚡ Расширенные", + description="Web API, глубокие ссылки и Redis", + category_keys=("WEB_API", "WEBHOOK", "LOG", "DEBUG", "ADDITIONAL", "REDIS"), ), ) + CATEGORY_FALLBACK_KEY = "other" CATEGORY_FALLBACK_TITLE = "📦 Прочие настройки" +ASSIGNED_CATEGORY_KEYS: set[str] = { + key + for menu_category in MENU_CATEGORIES + for key in menu_category.category_keys +} + + +SENSITIVE_KEYWORDS = ( + "TOKEN", + "SECRET", + "PASSWORD", + "API_KEY", + "SECRET_KEY", + "WEBHOOK_SECRET", + "SIGNATURE", + "PRIVATE", +) + +LIST_KEYWORDS = ( + "LIST", + "IDS", + "PERIODS", + "ASSETS", + "LANGUAGES", + "PACKAGES", + "WARNING_DAYS", + "UUIDS", +) + +URL_KEYWORDS = ( + "URL", + "LINK", + "WEBHOOK", + "HOST", + "ENDPOINT", + "BASE_URL", +) + +TIME_KEYWORDS = ( + "_TIME", + "_HOUR", + "_HOURS", + "_MINUTE", + "_MINUTES", +) + + +SETTING_METADATA_OVERRIDES: Dict[str, SettingMetadata] = { + "MAINTENANCE_MODE": SettingMetadata( + description="Включает режим обслуживания и блокирует пользовательские действия.", + format_hint="Переключатель: вкл/выкл.", + example="выкл", + warning="При включении пользователи увидят сообщение об обслуживании.", + dependencies="MAINTENANCE_MESSAGE — текст уведомления для пользователей.", + recommended="false", + highlight="Критический параметр", + ), + "MAINTENANCE_MESSAGE": SettingMetadata( + description="Сообщение, которое увидят пользователи во время технических работ.", + format_hint="Текст до 512 символов, поддерживается Markdown.", + example="🔧 Ведутся технические работы...", + warning="Избегайте раскрытия внутренних данных, сообщение видят все.", + ), + "SUPPORT_USERNAME": SettingMetadata( + description="Имя пользователя или ссылка для связи с поддержкой.", + format_hint="Telegram username в формате @username или https://t.me/...", + example="@bedolaga_support", + dependencies="SUPPORT_SYSTEM_MODE — режим обработки запросов.", + ), + "SUPPORT_TICKET_SLA_MINUTES": SettingMetadata( + description="Срок (в минутах) для ответа модератора на тикет.", + format_hint="Целое число от 1 до 1440.", + example="15", + unit="минут", + warning="При превышении времени бот отправит напоминание администраторам.", + ), + "TRIAL_DURATION_DAYS": SettingMetadata( + description="Длительность бесплатного периода подписки.", + format_hint="Целое число дней.", + example="3", + unit="дней", + recommended="3", + ), + "TRIAL_TRAFFIC_LIMIT_GB": SettingMetadata( + description="Лимит трафика в гигабайтах для триала.", + format_hint="Целое число", + example="10", + unit="ГБ", + ), + "TRIAL_DEVICE_LIMIT": SettingMetadata( + description="Количество устройств, доступных в триале.", + format_hint="Целое число", + example="2", + unit="устройств", + ), + "YOOKASSA_ENABLED": SettingMetadata( + description="Включает прием платежей через YooKassa.", + format_hint="Переключатель", + warning="Убедитесь, что Shop ID и секреты заполнены, иначе платежи не будут работать.", + dependencies="YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY, YOOKASSA_RETURN_URL", + ), + "YOOKASSA_SHOP_ID": SettingMetadata( + description="Идентификатор магазина в YooKassa.", + format_hint="Строка, как указано в личном кабинете YooKassa.", + example="123456", + sensitive=True, + ), + "YOOKASSA_SECRET_KEY": SettingMetadata( + description="Секретный ключ для подписи запросов YooKassa.", + format_hint="Строка 32-64 символа.", + example="sk_test_***", + warning="Храните ключ в секрете, не делитесь им с третьими лицами.", + sensitive=True, + ), + "BASE_SUBSCRIPTION_PRICE": SettingMetadata( + description="Базовая стоимость тарифа по умолчанию в копейках.", + format_hint="Введите значение в рублях — бот сконвертирует в копейки.", + example="990 ₽", + unit="₽", + ), + "AVAILABLE_SUBSCRIPTION_PERIODS": SettingMetadata( + description="Список доступных периодов подписки в днях.", + format_hint="Числа через запятую (например, 30,90,180).", + example="14,30,90", + dependencies="PRICE_XX_DAYS — стоимость для каждого периода.", + ), + "REMNAWAVE_API_URL": SettingMetadata( + description="Базовый URL RemnaWave API.", + format_hint="Полный URL, например https://panel.example.com/api.", + example="https://remnawave.example/api", + dependencies="REMNAWAVE_API_KEY или REMNAWAVE_USERNAME/REMNAWAVE_PASSWORD", + ), + "REMNAWAVE_API_KEY": SettingMetadata( + description="API-ключ для авторизации в RemnaWave.", + format_hint="Строка, выданная панелью RemnaWave.", + example="rw_************************", + sensitive=True, + ), + "REMNAWAVE_SECRET_KEY": SettingMetadata( + description="Секретный ключ для подписи запросов RemnaWave.", + sensitive=True, + warning="Неверный ключ приведет к ошибке синхронизации пользователей.", + ), + "ENABLE_NOTIFICATIONS": SettingMetadata( + description="Глобальный переключатель пользовательских уведомлений.", + format_hint="Переключатель", + warning="При выключении пользователи не будут получать напоминания и предупреждения.", + dependencies="NOTIFICATION_RETRY_ATTEMPTS, NOTIFICATION_CACHE_HOURS", + ), + "ADMIN_REPORTS_SEND_TIME": SettingMetadata( + description="Время отправки ежедневных отчетов администраторам.", + format_hint="Формат ЧЧ:ММ в часовом поясе бота.", + example="09:30", + dependencies="ADMIN_REPORTS_ENABLED должно быть включено.", + ), + "WEB_API_ENABLED": SettingMetadata( + description="Включает административное Web API.", + format_hint="Переключатель", + warning="Убедитесь, что настроены токены и ограничения доступа.", + dependencies="WEB_API_DEFAULT_TOKEN, WEB_API_ALLOWED_ORIGINS", + ), + "WEB_API_DEFAULT_TOKEN": SettingMetadata( + description="Бутстрап токен для доступа к Web API.", + format_hint="Строка из безопасных символов.", + example="rw_api_***", + sensitive=True, + warning="После смены токена требуется обновить интеграции.", + ), + "PAYMENT_SERVICE_NAME": SettingMetadata( + description="Название сервиса в платежных назначениях.", + format_hint="Краткий текст 2-32 символа.", + example="Bedolaga VPN", + ), + "TELEGRAM_STARS_ENABLED": SettingMetadata( + description="Включает оплату через Telegram Stars.", + format_hint="Переключатель", + warning="Доступно только при правильной настройке связанных приложений Telegram.", + ), + "CRYPTOBOT_ENABLED": SettingMetadata( + description="Активирует прием платежей через CryptoBot.", + warning="Проверьте токен и секрет вебхука перед включением.", + ), + "PAL24_ENABLED": SettingMetadata( + description="Включает PayPalych (PAL24).", + warning="Не забудьте задать токен и параметры вебхука.", + ), + "MULENPAY_ENABLED": SettingMetadata( + description="Активирует MulenPay.", + warning="Необходимы API KEY и SECRET KEY из панели MulenPay.", + ), + "TRIBUTE_ENABLED": SettingMetadata( + description="Разрешает платежи через Tribute.", + warning="Убедитесь в корректном API ключе и настроенном вебхуке.", + ), +} + + +CATEGORY_SECTION_DESCRIPTIONS: Dict[str, str] = { + "CHANNEL": "Настройки обязательной подписки и ссылок на канал.", + "SKIP": "Переключатели упрощенного сценария и пропуска шагов.", + "CONNECT_BUTTON": "Действие кнопки «Подключиться» и связанные ссылки.", + "SUPPORT": "Контакты поддержки, тикеты и SLA.", + "PAYMENT": "Общие шаблоны описаний и назначения платежей.", + "YOOKASSA": "Интеграция и параметры провайдера YooKassa.", + "CRYPTOBOT": "Параметры оплаты через CryptoBot.", + "MULENPAY": "Настройки провайдера MulenPay.", + "PAL24": "Интеграция PayPalych (PAL24).", + "TRIBUTE": "Сбор пожертвований через Tribute.", + "TELEGRAM": "Оплата через Telegram Stars.", + "PAID_SUBSCRIPTION": "Базовые параметры платных подписок.", + "PERIODS": "Доступные периоды продления подписки.", + "SUBSCRIPTION_PRICES": "Цены на подписки по периодам.", + "TRAFFIC": "Лимиты трафика и стратегии сброса.", + "TRAFFIC_PACKAGES": "Дополнительные пакеты трафика и их стоимость.", + "DISCOUNTS": "Промогруппы и скидки.", + "AUTOPAY": "Настройки автоматического продления подписки.", + "TRIAL": "Параметры пробного периода.", + "REFERRAL": "Параметры реферальной программы и уведомлений.", + "ADMIN_NOTIFICATIONS": "Уведомления администраторам о событиях.", + "ADMIN_REPORTS": "Автоматические отчеты для администраторов.", + "NOTIFICATIONS": "Пользовательские уведомления и ретраи.", + "INTERFACE_BRANDING": "Логотип и визуальные элементы интерфейса.", + "INTERFACE_SUBSCRIPTION": "Параметры отображения ссылок на подписку.", + "HAPP": "Ссылки на приложения Happ и CryptoLink.", + "MINIAPP": "Данные и описание мини-приложения.", + "LOCALIZATION": "Доступные языки и язык по умолчанию.", + "DATABASE": "Выбор движка базы данных.", + "POSTGRES": "Параметры подключения к PostgreSQL.", + "SQLITE": "Путь к базе SQLite.", + "REMNAWAVE": "Доступ к RemnaWave API.", + "SERVER": "Режимы отображения статуса серверов.", + "MONITORING": "Мониторинг и хранение логов.", + "MAINTENANCE": "Режим обслуживания и сообщения пользователям.", + "BACKUP": "Автоматические бэкапы и их отправка.", + "VERSION": "Проверка обновлений бота.", + "WEB_API": "Параметры административного Web API.", + "WEBHOOK": "Настройки входящего вебхука бота.", + "LOG": "Логирование и уровни логов.", + "DEBUG": "Режим отладки и дополнительные флаги.", + "ADDITIONAL": "Файл конфигурации и глубокие ссылки.", + "REDIS": "Подключение к Redis.", +} + + +SETTINGS_HISTORY: deque[Dict[str, Any]] = deque(maxlen=10) + +PREDEFINED_PRESETS: Dict[str, Dict[str, Any]] = { + "recommended": { + "MAINTENANCE_MODE": False, + "ENABLE_NOTIFICATIONS": True, + "SUPPORT_TICKET_SLA_MINUTES": 5, + "TRIAL_DURATION_DAYS": 3, + "WEB_API_ENABLED": False, + }, + "minimal": { + "ENABLE_NOTIFICATIONS": False, + "SUPPORT_MENU_ENABLED": False, + "TRIAL_DURATION_DAYS": 0, + "TRIAL_TRAFFIC_LIMIT_GB": 5, + "MAINTENANCE_MODE": False, + }, + "security": { + "ENABLE_NOTIFICATIONS": True, + "RESET_TRAFFIC_ON_PAYMENT": True, + "MAINTENANCE_AUTO_ENABLE": True, + "WEB_API_ENABLED": False, + "YOOKASSA_SBP_ENABLED": False, + }, + "testing": { + "DEBUG": True, + "ENABLE_NOTIFICATIONS": False, + "MAINTENANCE_MODE": True, + "WEB_API_ENABLED": True, + }, +} + +PRESET_TITLES: Dict[str, str] = { + "recommended": "Рекомендуемые настройки", + "minimal": "Минимальная конфигурация", + "security": "Максимальная безопасность", + "testing": "Для тестирования", +} + +CUSTOM_PRESETS: Dict[str, Dict[str, Any]] = {} + + +def _log_setting_change(key: str, old_value: Any, new_value: Any, source: str) -> None: + SETTINGS_HISTORY.appendleft( + { + "timestamp": datetime.utcnow(), + "key": key, + "old": old_value, + "new": new_value, + "source": source, + } + ) + + async def _store_setting_context( state: FSMContext, *, @@ -185,15 +572,15 @@ def _get_grouped_categories() -> List[Tuple[str, str, List[Tuple[str, str, int]] used: set[str] = set() grouped: List[Tuple[str, str, List[Tuple[str, str, int]]]] = [] - for group_key, title, category_keys in CATEGORY_GROUP_DEFINITIONS: + for menu_category in MENU_CATEGORIES: items: List[Tuple[str, str, int]] = [] - for category_key in category_keys: + for category_key in menu_category.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)) + grouped.append((menu_category.key, menu_category.title, items)) remaining = [ (key, label, count) @@ -208,21 +595,404 @@ def _get_grouped_categories() -> List[Tuple[str, str, List[Tuple[str, str, int]] return grouped +def _resolve_menu_category(menu_key: str) -> Optional[MenuCategoryDefinition]: + for menu_category in MENU_CATEGORIES: + if menu_category.key == menu_key: + return menu_category + return None + + +def _collect_menu_sections( + menu_key: str, +) -> List[Tuple[str, str, List[SettingDefinition]]]: + sections: List[Tuple[str, str, List[SettingDefinition]]] = [] + + if menu_key == CATEGORY_FALLBACK_KEY: + for category_key, label, _ in bot_configuration_service.get_categories(): + if category_key in ASSIGNED_CATEGORY_KEYS: + continue + definitions = bot_configuration_service.get_settings_for_category(category_key) + if definitions: + sections.append((category_key, label, definitions)) + return sections + + menu_category = _resolve_menu_category(menu_key) + if not menu_category: + return sections + + for category_key in menu_category.category_keys: + definitions = bot_configuration_service.get_settings_for_category(category_key) + if definitions: + sections.append((category_key, definitions[0].category_label, definitions)) + return sections + + +def _collect_menu_definitions(menu_key: str) -> List[SettingDefinition]: + sections = _collect_menu_sections(menu_key) + definitions: List[SettingDefinition] = [] + seen: set[str] = set() + + for _, _, items in sections: + for definition in items: + if definition.key in seen: + continue + definitions.append(definition) + seen.add(definition.key) + + definitions.sort(key=lambda definition: definition.display_name.lower()) + return definitions + + +def _find_menu_key_for_category(category_key: str) -> str: + for menu_category in MENU_CATEGORIES: + if category_key in menu_category.category_keys: + return menu_category.key + return CATEGORY_FALLBACK_KEY + + +def _iter_all_definitions() -> Iterable[SettingDefinition]: + seen: set[str] = set() + for category_key, _, _ in bot_configuration_service.get_categories(): + for definition in bot_configuration_service.get_settings_for_category(category_key): + if definition.key in seen: + continue + seen.add(definition.key) + yield definition + + +def _determine_setting_kind(definition: SettingDefinition) -> SettingKind: + key_upper = definition.key.upper() + + if bot_configuration_service.has_choices(definition.key): + return SettingKind.CHOICE + + python_type = definition.python_type + + if python_type is bool: + return SettingKind.TOGGLE + + if python_type is int: + if key_upper.startswith("PRICE_") or key_upper.endswith("_KOPEKS"): + return SettingKind.PRICE + return SettingKind.NUMBER + + if python_type is float: + return SettingKind.FLOAT + + if python_type is str: + if any(keyword in key_upper for keyword in TIME_KEYWORDS): + return SettingKind.TIME + if any(keyword in key_upper for keyword in URL_KEYWORDS): + return SettingKind.URL + if any(keyword in key_upper for keyword in LIST_KEYWORDS): + return SettingKind.LIST + if any(keyword in key_upper for keyword in SENSITIVE_KEYWORDS): + return SettingKind.SECRET + return SettingKind.TEXT + + return SettingKind.TEXT + + +def _format_metadata_value(value: Any, kind: Optional[SettingKind]) -> str: + if value is None: + return "" + if kind == SettingKind.PRICE: + try: + rubles = float(value) / 100 + return f"{rubles:.0f}" + except Exception: + return str(value) + if kind == SettingKind.TOGGLE: + return "вкл" if bool(value) else "выкл" + if isinstance(value, (list, tuple, set)): + return ",".join(str(item) for item in value) + return str(value) + + +def _mask_sensitive(value: Any) -> str: + text = str(value) + if not text: + return "—" + if len(text) <= 4: + return "•" * len(text) + return "•" * (len(text) - 4) + text[-4:] + + +def _format_price(kopeks: int) -> str: + try: + rubles = int(kopeks) / 100 + formatted = f"{rubles:,.0f}".replace(",", " ") + return f"{formatted} ₽" + except Exception: + return str(kopeks) + + +def _to_list(value: Any) -> List[str]: + if value is None: + return [] + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + if isinstance(value, (list, tuple, set)): + return [str(item).strip() for item in value if str(item).strip()] + return [str(value)] + + +def _format_setting_value( + definition: SettingDefinition, + value: Any, + metadata: SettingMetadata, + *, + short: bool = False, +) -> str: + if value is None or (isinstance(value, str) and not value.strip()): + return "—" + + kind = _determine_setting_kind(definition) + + if metadata.sensitive or kind == SettingKind.SECRET: + return _mask_sensitive(value) + + if kind == SettingKind.TOGGLE: + return "ВКЛЮЧЕНО" if bool(value) else "ВЫКЛЮЧЕНО" + + if kind == SettingKind.PRICE: + try: + return _format_price(int(value)) + except Exception: + return str(value) + + if kind == SettingKind.NUMBER: + return f"{value} {metadata.unit}".strip() if metadata.unit else str(value) + + if kind == SettingKind.FLOAT: + try: + return f"{float(value):g}" + except Exception: + return str(value) + + if kind == SettingKind.TIME: + return str(value) + + if kind == SettingKind.URL: + text = str(value) + return text if short else html.escape(text) + + if kind == SettingKind.LIST: + items = _to_list(value) + if short: + preview = ", ".join(items[:3]) + if len(items) > 3: + preview += " …" + return preview or "—" + return "\n".join(f"• {html.escape(item)}" for item in items) or "—" + + if kind == SettingKind.CHOICE: + return html.escape(str(value)) + + return html.escape(str(value)) if not short else str(value) + + +def _setting_status_icon(definition: SettingDefinition, value: Any) -> str: + kind = _determine_setting_kind(definition) + if kind == SettingKind.TOGGLE: + return "✅" if bool(value) else "❌" + if value is None or (isinstance(value, str) and not value.strip()): + return "⚠️" + if bot_configuration_service.has_override(definition.key): + return "🛠" + return "📌" + + +def _get_setting_metadata( + key: str, + definition: Optional[SettingDefinition] = None, +) -> SettingMetadata: + override = SETTING_METADATA_OVERRIDES.get(key) + if override: + return replace(override) + + if definition is None: + try: + definition = bot_configuration_service.get_definition(key) + except KeyError: + definition = None + + kind: Optional[SettingKind] = None + if definition is not None: + kind = _determine_setting_kind(definition) + + description = "" + format_hint = "" + example = "" + warning = "" + dependencies = "" + unit = None + + if kind == SettingKind.TOGGLE: + description = "Переключатель функциональности." + format_hint = "Используйте вкл/выкл или on/off." + elif kind == SettingKind.PRICE: + description = "Стоимость в копейках." + format_hint = "Введите значение в рублях, бот автоматически переведет в копейки." + unit = "₽" + elif kind == SettingKind.LIST: + description = "Список значений через запятую." + format_hint = "Разделяйте значения запятыми, пробелы допустимы." + elif kind == SettingKind.TIME: + description = "Время в формате ЧЧ:ММ." + format_hint = "Используйте 24-часовой формат, например 09:30." + elif kind == SettingKind.URL: + description = "URL или ссылка." + format_hint = "Введите полный адрес, начинающийся с http:// или https://." + elif kind == SettingKind.NUMBER: + description = "Целочисленное значение." + format_hint = "Введите целое число." + elif kind == SettingKind.FLOAT: + description = "Число с плавающей точкой." + format_hint = "Используйте точку или запятую в качестве разделителя дробной части." + elif kind == SettingKind.TEXT: + description = "Текстовое значение." + format_hint = "Любая строка, максимум 1024 символа." + elif kind == SettingKind.CHOICE: + description = "Выбор из доступных вариантов." + format_hint = "Выберите значение из предложенных кнопок." + + original_value = None + if definition is not None: + original_value = bot_configuration_service.get_original_value(key) + if not example: + example = _format_metadata_value(original_value, kind) + + metadata = SettingMetadata( + description=description or (f"Параметр {key}." if not definition else f"Параметр {definition.display_name}.") , + format_hint=format_hint, + example=example, + warning=warning, + dependencies=dependencies, + recommended=_format_metadata_value(original_value, kind) or None, + sensitive=(kind == SettingKind.SECRET), + unit=unit, + ) + + return metadata + + +def _summarize_definitions(definitions: List[SettingDefinition]) -> Dict[str, Any]: + total = len(definitions) + overrides = 0 + missing_required = 0 + disabled_flags = 0 + enabled_flags = 0 + issues: List[str] = [] + + for definition in definitions: + value = bot_configuration_service.get_current_value(definition.key) + kind = _determine_setting_kind(definition) + + if bot_configuration_service.has_override(definition.key): + overrides += 1 + + if kind == SettingKind.TOGGLE: + if bool(value): + enabled_flags += 1 + else: + disabled_flags += 1 + if definition.key.endswith("ENABLED") or "ENABLE" in definition.key: + issues.append(f"{definition.display_name}: выключено") + else: + if not definition.is_optional: + if value is None: + missing_required += 1 + issues.append(f"{definition.display_name}: не заполнено") + elif isinstance(value, str) and not value.strip(): + missing_required += 1 + issues.append(f"{definition.display_name}: пустое значение") + + if missing_required: + status_icon = "🔴" + status_text = f"{missing_required} критически важных параметров не заполнено" + elif disabled_flags and not enabled_flags: + status_icon = "🟡" + status_text = "Все функции категории выключены" + elif disabled_flags: + status_icon = "🟡" + status_text = "Некоторые функции отключены" + else: + status_icon = "🟢" + status_text = "Всё настроено корректно" + + return { + "total": total, + "overrides": overrides, + "enabled_flags": enabled_flags, + "disabled_flags": disabled_flags, + "missing_required": missing_required, + "issues": issues, + "status_icon": status_icon, + "status_text": status_text, + } + + +def _summarize_menu_category(menu_key: str) -> Dict[str, Any]: + definitions = _collect_menu_definitions(menu_key) + return _summarize_definitions(definitions) + + def _build_groups_keyboard() -> types.InlineKeyboardMarkup: grouped = _get_grouped_categories() rows: list[list[types.InlineKeyboardButton]] = [] + buttons: list[types.InlineKeyboardButton] = [] for group_key, title, items in grouped: - total = sum(count for _, _, count in items) - rows.append( - [ - types.InlineKeyboardButton( - text=f"{title} ({total})", - callback_data=f"botcfg_group:{group_key}:1", - ) - ] + summary = _summarize_menu_category(group_key) + total = summary["total"] or sum(count for _, _, count in items) + button_text = f"{summary['status_icon']} {title} · {total}" + buttons.append( + types.InlineKeyboardButton( + text=button_text, + callback_data=f"botcfg_group:{group_key}:1", + ) ) + for chunk in _chunk(buttons, 2): + rows.append(list(chunk)) + + rows.append( + [ + types.InlineKeyboardButton( + text="🔍 Найти настройку", + callback_data="botcfg_search", + ), + types.InlineKeyboardButton( + text="📊 История изменений", + callback_data="botcfg_history", + ), + ] + ) + rows.append( + [ + types.InlineKeyboardButton( + text="🎛 Пресеты", + callback_data="botcfg_presets", + ), + types.InlineKeyboardButton( + text="📤 Экспорт .env", + callback_data="botcfg_export", + ), + ] + ) + rows.append( + [ + types.InlineKeyboardButton( + text="📥 Импорт .env", + callback_data="botcfg_import", + ), + types.InlineKeyboardButton( + text="❓ Помощь", + callback_data="botcfg_help", + ), + ] + ) rows.append( [ types.InlineKeyboardButton( @@ -235,6 +1005,101 @@ def _build_groups_keyboard() -> types.InlineKeyboardMarkup: return types.InlineKeyboardMarkup(inline_keyboard=rows) +def _render_main_menu_text() -> str: + grouped = _get_grouped_categories() + total_settings = sum(sum(count for _, _, count in items) for _, _, items in grouped) + lines = [ + "⚙️ Панель управления ботом", + "Главная → Разделы", + "", + f"Всего параметров: {total_settings}", + "Выберите категорию, чтобы открыть настройки:", + "", + ] + + for group_key, title, _ in grouped: + summary = _summarize_menu_category(group_key) + menu_category = _resolve_menu_category(group_key) + description = menu_category.description if menu_category else "Дополнительные параметры" + overrides = summary["overrides"] + lines.append( + ( + f"{summary['status_icon']} {title} — {description}\n" + f" Настроек: {summary['total']} · Переопределено: {overrides}" + ) + ) + lines.append("") + lines.append("🔍 Используйте поиск, чтобы быстро найти нужный параметр.") + lines.append("💡 Пресеты помогут применить готовые конфигурации в пару кликов.") + + return "\n".join(lines) + + +def _render_group_text(group_key: str, group_title: str) -> str: + summary = _summarize_menu_category(group_key) + menu_category = _resolve_menu_category(group_key) + description = menu_category.description if menu_category else "Дополнительные параметры" + + lines = [ + "⚙️ Панель управления ботом", + f"Главная → {group_title}", + "", + f"{summary['status_icon']} {group_title}", + description, + "", + f"Настроек: {summary['total']} · Переопределено: {summary['overrides']}", + ] + + if summary["issues"]: + lines.append("") + lines.append("⚠️ Требует внимания:") + for issue in summary["issues"][:5]: + lines.append(f"• {issue}") + + lines.append("") + lines.append("Выберите подкатегорию:") + + return "\n".join(lines) + + +def _render_category_text( + group_key: str, + category_key: str, + category_label: str, + definitions: List[SettingDefinition], +) -> str: + menu_category = _resolve_menu_category(group_key) + breadcrumb = ( + f"Главная → {menu_category.title if menu_category else CATEGORY_FALLBACK_TITLE} → {category_label}" + ) + summary = _summarize_definitions(definitions) + description = CATEGORY_SECTION_DESCRIPTIONS.get( + category_key, + f"Настройки блока «{category_label}».", + ) + + lines = [ + "⚙️ Панель управления ботом", + breadcrumb, + "", + f"{summary['status_icon']} {category_label}", + description, + "", + f"Настроек: {summary['total']} · Переопределено: {summary['overrides']}", + ] + + if summary["issues"]: + lines.append("") + lines.append("⚠️ Требует внимания:") + for issue in summary["issues"][:5]: + lines.append(f"• {issue}") + + lines.append("") + lines.append("Выберите настройку для просмотра и изменения:") + + return "\n".join(lines) + + def _build_categories_keyboard( group_key: str, group_title: str, @@ -260,7 +1125,12 @@ def _build_categories_keyboard( buttons: List[types.InlineKeyboardButton] = [] for category_key, label, count in sliced: - button_text = f"{label} ({count})" + definitions = bot_configuration_service.get_settings_for_category(category_key) + summary = _summarize_definitions(definitions) + overrides = summary["overrides"] + button_text = f"{summary['status_icon']} {label} · {summary['total']}" + if overrides: + button_text += f" • ⚙️{overrides}" buttons.append( types.InlineKeyboardButton( text=button_text, @@ -370,8 +1240,16 @@ def _build_settings_keyboard( 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}" + value = bot_configuration_service.get_current_value(definition.key) + metadata = _get_setting_metadata(definition.key, definition) + value_preview = _format_setting_value( + definition, + value, + metadata, + short=True, + ) + status_icon = _setting_status_icon(definition, value) + button_text = f"{status_icon} {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) @@ -432,6 +1310,9 @@ def _build_setting_keyboard( definition = bot_configuration_service.get_definition(key) rows: list[list[types.InlineKeyboardButton]] = [] callback_token = bot_configuration_service.get_callback_token(key) + metadata = _get_setting_metadata(key, definition) + current_value = bot_configuration_service.get_current_value(key) + kind = _determine_setting_kind(definition) choice_options = bot_configuration_service.get_choice_options(key) if choice_options: @@ -456,7 +1337,21 @@ def _build_setting_keyboard( for chunk in _chunk(choice_buttons, 2): rows.append(list(chunk)) - if definition.python_type is bool: + if kind == SettingKind.TOGGLE: + rows.append([ + types.InlineKeyboardButton( + text="✅ Включить", + callback_data=( + f"botcfg_bool:{group_key}:{category_page}:{settings_page}:{callback_token}:1" + ), + ), + types.InlineKeyboardButton( + text="❌ Выключить", + callback_data=( + f"botcfg_bool:{group_key}:{category_page}:{settings_page}:{callback_token}:0" + ), + ), + ]) rows.append([ types.InlineKeyboardButton( text="🔁 Переключить", @@ -475,6 +1370,41 @@ def _build_setting_keyboard( ) ]) + if metadata.recommended and metadata.recommended.strip(): + rows.append([ + types.InlineKeyboardButton( + text="💡 Рекомендуемое", + callback_data=( + f"botcfg_apply_rec:{group_key}:{category_page}:{settings_page}:{callback_token}" + ), + ) + ]) + + if kind == SettingKind.LIST: + rows.append([ + types.InlineKeyboardButton( + text="➕ Добавить", + callback_data=( + f"botcfg_list_add:{group_key}:{category_page}:{settings_page}:{callback_token}" + ), + ), + types.InlineKeyboardButton( + text="➖ Удалить", + callback_data=( + f"botcfg_list_remove:{group_key}:{category_page}:{settings_page}:{callback_token}" + ), + ), + ]) + + rows.append([ + types.InlineKeyboardButton( + text="📋 Копировать из…", + callback_data=( + f"botcfg_copy:{group_key}:{category_page}:{settings_page}:{callback_token}" + ), + ) + ]) + if bot_configuration_service.has_override(key): rows.append([ types.InlineKeyboardButton( @@ -499,18 +1429,69 @@ def _build_setting_keyboard( def _render_setting_text(key: str) -> str: summary = bot_configuration_service.get_setting_summary(key) + definition = bot_configuration_service.get_definition(key) + metadata = _get_setting_metadata(key, definition) + current_value = bot_configuration_service.get_current_value(key) + original_value = bot_configuration_service.get_original_value(key) + category_key = summary["category_key"] + group_key = _find_menu_key_for_category(category_key) + menu_category = _resolve_menu_category(group_key) + + current_display = _format_setting_value(definition, current_value, metadata, short=False) + original_display = ( + _format_setting_value(definition, original_value, metadata, short=False) + if original_value not in (None, "") + else "—" + ) + status_icon = _setting_status_icon(definition, current_value) lines = [ - "🧩 Настройка", - 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 '❌ Нет'}", + "⚙️ Панель управления ботом", + ( + f"Главная → {menu_category.title if menu_category else CATEGORY_FALLBACK_TITLE} → " + f"{summary['category_label']} → {summary['name']}" + ), + "", + f"{status_icon} {summary['name']}", + metadata.description or f"Параметр {summary['name']}.", + "", + f"🔑 Ключ: {summary['key']}", + f"🧩 Категория: {summary['category_label']}", + f"🧷 Тип: {summary['type']}", + "", + f"{status_icon} Текущее значение: {current_display}", + f"📦 По умолчанию: {original_display}", + f"🛠 Переопределено: {'Да' if summary['has_override'] else 'Нет'}", ] + if metadata.recommended and metadata.recommended.strip(): + try: + recommended_value = bot_configuration_service.parse_user_value( + key, metadata.recommended + ) + recommended_display = _format_setting_value( + definition, recommended_value, metadata, short=False + ) + except ValueError: + recommended_display = metadata.recommended + lines.append(f"💡 Рекомендуемое: {recommended_display}") + + if metadata.format_hint: + lines.append("") + lines.append(f"ℹ️ Формат: {metadata.format_hint}") + + if metadata.unit: + lines.append(f"📏 Единицы измерения: {metadata.unit}") + + if metadata.dependencies: + lines.append(f"🔗 Связанные параметры: {metadata.dependencies}") + + if metadata.warning: + lines.append(f"⚠️ Предупреждение: {metadata.warning}") + + if metadata.doc_link: + lines.append(f"📘 Документация") + choices = bot_configuration_service.get_choice_options(key) if choices: current_raw = bot_configuration_service.get_current_value(key) @@ -518,7 +1499,7 @@ def _render_setting_text(key: str) -> str: lines.append("Доступные значения:") for option in choices: marker = "✅" if current_raw == option.value else "•" - value_display = bot_configuration_service.format_value(option.value) + value_display = html.escape(str(option.value)) description = option.description or "" if description: lines.append( @@ -527,6 +1508,10 @@ def _render_setting_text(key: str) -> str: else: lines.append(f"{marker} {option.label} — {value_display}") + if metadata.highlight: + lines.append("") + lines.append(f"✨ {metadata.highlight}") + return "\n".join(lines) @@ -538,9 +1523,11 @@ async def show_bot_config_menu( db: AsyncSession, ): keyboard = _build_groups_keyboard() + text = _render_main_menu_text() await callback.message.edit_text( - "🧩 Конфигурация бота\n\nВыберите раздел настроек:", + text, reply_markup=keyboard, + parse_mode="HTML", ) await callback.answer() @@ -562,9 +1549,11 @@ async def show_bot_config_group( group_title, items = group_lookup[group_key] keyboard = _build_categories_keyboard(group_key, group_title, items, page) + text = _render_group_text(group_key, group_title) await callback.message.edit_text( - f"🧩 {group_title}\n\nВыберите категорию настроек:", + text, reply_markup=keyboard, + parse_mode="HTML", ) await callback.answer() @@ -593,9 +1582,11 @@ async def show_bot_config_category( db_user.language, settings_page, ) + text = _render_category_text(group_key, category_key, category_label, definitions) await callback.message.edit_text( - f"🧩 {category_label}\n\nВыберите настройку для просмотра:", + text, reply_markup=keyboard, + parse_mode="HTML", ) await callback.answer() @@ -1070,7 +2061,7 @@ async def show_bot_config_setting( return 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 callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") await _store_setting_context( state, key=key, @@ -1079,6 +2070,7 @@ async def show_bot_config_setting( settings_page=settings_page, ) await callback.answer() + await callback.answer() @admin_required @@ -1170,6 +2162,7 @@ async def handle_edit_setting( await state.clear() return + old_value = bot_configuration_service.get_current_value(key) try: value = bot_configuration_service.parse_user_value(key, message.text or "") except ValueError as error: @@ -1178,6 +2171,7 @@ async def handle_edit_setting( await bot_configuration_service.set_value(db, key, value) await db.commit() + _log_setting_change(key, old_value, value, "manual_input") text = _render_setting_text(key) keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page) @@ -1217,13 +2211,15 @@ async def handle_direct_setting_input( await message.answer(f"⚠️ {error}") return + old_value = bot_configuration_service.get_current_value(key) await bot_configuration_service.set_value(db, key, value) await db.commit() + _log_setting_change(key, old_value, value, "direct_input") 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) + await message.answer(text, reply_markup=keyboard, parse_mode="HTML") await state.clear() await _store_setting_context( @@ -1235,6 +2231,585 @@ async def handle_direct_setting_input( ) +@admin_required +@error_handler +async def handle_list_input( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + data = await state.get_data() + key = data.get("setting_key") + operation = data.get("list_operation") + 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) + + if not key or operation not in {"add", "remove"}: + await message.answer("⚠️ Не удалось определить список для изменения. Попробуйте снова.") + await state.clear() + return + + definition = bot_configuration_service.get_definition(key) + current_raw = bot_configuration_service.get_current_value(key) + items = _to_list(current_raw) + + raw_text = (message.text or "").strip() + if not raw_text: + await message.answer("⚠️ Введите хотя бы одно значение.") + return + + new_elements = [element.strip() for element in raw_text.split(",") if element.strip()] + if not new_elements: + await message.answer("⚠️ Введите хотя бы одно значение.") + return + + changed = False + if operation == "add": + for element in new_elements: + if element not in items: + items.append(element) + changed = True + if not changed: + await message.answer("ℹ️ Эти значения уже присутствуют в списке.") + return + else: + removed = 0 + for element in new_elements: + while element in items: + items.remove(element) + removed += 1 + if removed == 0: + await message.answer("ℹ️ Ни одно из указанных значений не найдено в списке.") + return + + new_value_str = ", ".join(items) + old_value = current_raw + await bot_configuration_service.set_value(db, key, new_value_str) + await db.commit() + _log_setting_change(key, old_value, new_value_str, f"list_{operation}") + + await message.answer("✅ Список обновлен") + text = _render_setting_text(key) + keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page) + await message.answer(text, reply_markup=keyboard, parse_mode="HTML") + + await state.clear() + await _store_setting_context( + state, + key=key, + group_key=group_key, + category_page=category_page, + settings_page=settings_page, + ) + + +@admin_required +@error_handler +async def start_copy_setting( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + parts = callback.data.split(":", 5) + 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: + 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 + + await _store_setting_context( + state, + key=key, + group_key=group_key, + category_page=category_page, + settings_page=settings_page, + ) + await state.set_state(BotConfigStates.waiting_for_copy_source) + await callback.message.answer( + "📋 Копирование значения\n\n" + "Введите ключ настройки, из которой нужно скопировать значение.", + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def handle_copy_source( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + data = await state.get_data() + target_key = data.get("setting_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) + + if not target_key: + await message.answer("⚠️ Не удалось определить целевую настройку.") + await state.clear() + return + + source_key = (message.text or "").strip().upper() + if not source_key: + await message.answer("⚠️ Укажите ключ настройки.") + return + + try: + bot_configuration_service.get_definition(source_key) + except KeyError: + await message.answer("⚠️ Такой настройки не существует.") + return + + source_value = bot_configuration_service.get_current_value(source_key) + serialized = bot_configuration_service.serialize_value(source_key, source_value) + if serialized is None: + serialized = "" + + try: + parsed_value = bot_configuration_service.parse_user_value(target_key, serialized) + except ValueError as error: + await message.answer(f"⚠️ Не удалось преобразовать значение: {error}") + return + + old_value = bot_configuration_service.get_current_value(target_key) + await bot_configuration_service.set_value(db, target_key, parsed_value) + await db.commit() + _log_setting_change(target_key, old_value, parsed_value, f"copy:{source_key}") + + await message.answer("✅ Значение скопировано") + text = _render_setting_text(target_key) + keyboard = _build_setting_keyboard(target_key, group_key, category_page, settings_page) + await message.answer(text, reply_markup=keyboard, parse_mode="HTML") + + await state.clear() + await _store_setting_context( + state, + key=target_key, + group_key=group_key, + category_page=category_page, + settings_page=settings_page, + ) + + +@admin_required +@error_handler +async def start_settings_search( + 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", botcfg_timestamp=time.time()) + await callback.message.answer( + "🔍 Поиск по настройкам\n\nВведите название, ключ или описание настройки.", + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def handle_search_query( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + query = (message.text or "").strip().lower() + if len(query) < 2: + await message.answer("⚠️ Поисковый запрос должен содержать минимум 2 символа.") + return + + results = [] + for definition in _iter_all_definitions(): + metadata = _get_setting_metadata(definition.key, definition) + haystack = " ".join( + filter( + None, + [ + definition.display_name.lower(), + definition.key.lower(), + (metadata.description or "").lower(), + (metadata.format_hint or "").lower(), + ], + ) + ) + if query in haystack: + results.append(definition) + if len(results) >= 20: + break + + if not results: + await message.answer("ℹ️ Ничего не найдено.") + await state.clear() + return + + rows: list[list[types.InlineKeyboardButton]] = [] + for definition in results: + group_key = _find_menu_key_for_category(definition.category_key) + callback_token = bot_configuration_service.get_callback_token(definition.key) + current_value = bot_configuration_service.get_current_value(definition.key) + status_icon = _setting_status_icon(definition, current_value) + rows.append( + [ + types.InlineKeyboardButton( + text=f"{status_icon} {definition.display_name}", + callback_data=f"botcfg_setting:{group_key}:1:1:{callback_token}", + ) + ] + ) + + summary_lines = [ + "🔍 Результаты поиска", + f"Найдено: {len(results)}", + "Выберите настройку из списка:", + ] + + await message.answer( + "\n".join(summary_lines), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows), + parse_mode="HTML", + ) + await state.clear() + + +@admin_required +@error_handler +async def show_history( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + if not SETTINGS_HISTORY: + await callback.message.answer("ℹ️ История изменений пуста.") + await callback.answer() + return + + lines = ["📊 Последние изменения настроек"] + + for entry in list(SETTINGS_HISTORY)[:10]: + key = entry["key"] + timestamp: datetime = entry["timestamp"] + source = entry.get("source", "manual") + try: + definition = bot_configuration_service.get_definition(key) + metadata = _get_setting_metadata(key, definition) + old_display = _format_setting_value(definition, entry.get("old"), metadata, short=True) + new_display = _format_setting_value(definition, entry.get("new"), metadata, short=True) + except KeyError: + old_display = str(entry.get("old")) + new_display = str(entry.get("new")) + time_str = timestamp.strftime("%d.%m %H:%M") + lines.append( + f"• {key} ({time_str})\n" + f" было: {old_display}\n стало: {new_display}\n источник: {source}" + ) + + await callback.message.answer("\n".join(lines), parse_mode="HTML") + await callback.answer() + + +@admin_required +@error_handler +async def show_presets_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + rows: list[list[types.InlineKeyboardButton]] = [] + for preset_key, title in PRESET_TITLES.items(): + rows.append([ + types.InlineKeyboardButton( + text=f"🎯 {title}", + callback_data=f"botcfg_apply_preset:{preset_key}", + ) + ]) + + if CUSTOM_PRESETS: + for name, payload in sorted(CUSTOM_PRESETS.items()): + rows.append([ + types.InlineKeyboardButton( + text=f"⭐ {payload.get('title', name)}", + callback_data=f"botcfg_apply_preset:custom:{name}", + ) + ]) + + rows.append([ + types.InlineKeyboardButton( + text="💾 Сохранить текущие настройки", + callback_data="botcfg_save_preset", + ) + ]) + rows.append([ + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_bot_config") + ]) + + description_lines = [ + "🎛 Пресеты настроек", + "Быстро применяйте готовые конфигурации или сохраните свою.", + ] + + await callback.message.answer( + "\n".join(description_lines), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows), + parse_mode="HTML", + ) + await callback.answer() + + +async def _apply_preset_values( + db: AsyncSession, + preset_values: Dict[str, Any], + *, + source: str, +) -> List[str]: + applied: List[str] = [] + for key, raw_value in preset_values.items(): + try: + definition = bot_configuration_service.get_definition(key) + except KeyError: + continue + + old_value = bot_configuration_service.get_current_value(key) + + if isinstance(raw_value, str): + try: + new_value = bot_configuration_service.parse_user_value(key, raw_value) + except ValueError: + continue + else: + new_value = raw_value + + if new_value == old_value: + continue + + await bot_configuration_service.set_value(db, key, new_value) + applied.append(key) + _log_setting_change(key, old_value, new_value, source) + + if applied: + await db.commit() + + return applied + + +@admin_required +@error_handler +async def apply_preset( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + parts = callback.data.split(":", 1) + preset_id = parts[1] if len(parts) > 1 else "" + + if preset_id.startswith("custom:"): + preset_key = preset_id.split(":", 1)[1] + preset_entry = CUSTOM_PRESETS.get(preset_key) + if not preset_entry: + await callback.answer("Этот пресет больше недоступен", show_alert=True) + return + preset_values = preset_entry.get("values", {}) + source = f"preset:custom:{preset_key}" + title = preset_entry.get("title", preset_key) + else: + if preset_id not in PRESET_TITLES: + await callback.answer("Неизвестный пресет", show_alert=True) + return + preset_values = PREDEFINED_PRESETS.get(preset_id, {}) + source = f"preset:{preset_id}" + title = PRESET_TITLES.get(preset_id, preset_id) + + applied_keys = await _apply_preset_values(db, preset_values, source=source) + + if not applied_keys: + await callback.answer("Настройки уже соответствуют пресету") + return + + summary = "\n".join(f"• {key}" for key in applied_keys) + await callback.message.answer( + f"✅ Пресет {title} применен. Обновлены параметры:\n{summary}", + 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 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_raw = (message.text or "").strip() + if not name_raw: + await message.answer("⚠️ Название не может быть пустым.") + return + + key = name_raw.lower().replace(" ", "_") + snapshot: Dict[str, str] = {} + for definition in _iter_all_definitions(): + value = bot_configuration_service.get_current_value(definition.key) + serialized = bot_configuration_service.serialize_value(definition.key, value) + snapshot[definition.key] = "" if serialized is None else serialized + + CUSTOM_PRESETS[key] = { + "title": name_raw, + "values": snapshot, + "created_at": datetime.utcnow(), + } + + await message.answer(f"✅ Пресет {name_raw} сохранен.", parse_mode="HTML") + await state.clear() + + +@admin_required +@error_handler +async def export_settings( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + lines: List[str] = [] + for definition in _iter_all_definitions(): + value = bot_configuration_service.get_current_value(definition.key) + serialized = bot_configuration_service.serialize_value(definition.key, value) + if serialized is None: + serialized = "" + lines.append(f"{definition.key}={serialized}") + + content = "\n".join(lines).encode("utf-8") + document = BufferedInputFile(content, filename="bot-settings.env") + await callback.message.answer_document( + document, + caption="📤 Экспорт актуальных настроек", + ) + 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) + await callback.message.answer( + "📥 Импорт настроек\n\nОтправьте содержимое .env файла сообщением.", + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def handle_import_settings( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + content = (message.text or "").strip() + if not content: + await message.answer("⚠️ Сообщение пустое. Отправьте содержимое .env файла.") + return + + changes: List[str] = [] + for line in content.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, raw_value = line.split("=", 1) + key = key.strip() + raw_value = raw_value.strip() + try: + bot_configuration_service.get_definition(key) + except KeyError: + continue + + old_value = bot_configuration_service.get_current_value(key) + try: + parsed_value = bot_configuration_service.parse_user_value(key, raw_value) + except ValueError: + continue + + if parsed_value == old_value: + continue + + await bot_configuration_service.set_value(db, key, parsed_value) + _log_setting_change(key, old_value, parsed_value, "import") + changes.append(key) + + if changes: + await db.commit() + summary = "\n".join(f"• {key}" for key in changes) + await message.answer( + f"✅ Импорт завершен. Обновлено {len(changes)} настроек:\n{summary}", + parse_mode="HTML", + ) + else: + await message.answer("ℹ️ Новых изменений не найдено.") + + await state.clear() + + +@admin_required +@error_handler +async def show_help( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + text = ( + "❓ Помощь по панели настроек\n\n" + "• Используйте поиск, чтобы быстро находить параметры.\n" + "• Пресеты помогают мгновенно применять проверенные конфигурации.\n" + "• История изменений хранит последние 10 операций.\n" + "• Экспортируйте .env перед большими изменениями и при необходимости импортируйте его обратно." + ) + await callback.message.answer(text, parse_mode="HTML") + await callback.answer() + + @admin_required @error_handler async def reset_setting( @@ -1259,12 +2834,15 @@ async def reset_setting( except KeyError: await callback.answer("Эта настройка больше недоступна", show_alert=True) return + old_value = bot_configuration_service.get_current_value(key) await bot_configuration_service.reset_value(db, key) await db.commit() + new_value = bot_configuration_service.get_current_value(key) + _log_setting_change(key, old_value, new_value, "reset") 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 callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") await _store_setting_context( state, key=key, @@ -1303,10 +2881,11 @@ async def toggle_setting( new_value = not bool(current) await bot_configuration_service.set_value(db, key, new_value) await db.commit() + _log_setting_change(key, current, new_value, "toggle") 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 callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") await _store_setting_context( state, key=key, @@ -1350,12 +2929,14 @@ async def apply_setting_choice( await callback.answer("Это значение больше недоступно", show_alert=True) return + old_value = bot_configuration_service.get_current_value(key) await bot_configuration_service.set_value(db, key, value) await db.commit() + _log_setting_change(key, old_value, value, "choice") 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 callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") await _store_setting_context( state, key=key, @@ -1366,6 +2947,193 @@ async def apply_setting_choice( await callback.answer("Значение обновлено") +@admin_required +@error_handler +async def set_boolean_setting( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + parts = callback.data.split(":", 6) + 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: + 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 "" + desired_flag = parts[5] if len(parts) > 5 else "1" + + try: + key = bot_configuration_service.resolve_callback_token(token) + except KeyError: + await callback.answer("Эта настройка больше недоступна", show_alert=True) + return + + target_value = desired_flag == "1" + current_value = bool(bot_configuration_service.get_current_value(key)) + + if current_value == target_value: + await callback.answer("Значение уже установлено") + return + + await bot_configuration_service.set_value(db, key, target_value) + await db.commit() + _log_setting_change(key, current_value, target_value, "bool_set") + + 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 _store_setting_context( + state, + key=key, + group_key=group_key, + category_page=category_page, + settings_page=settings_page, + ) + await callback.answer("Обновлено") + + +@admin_required +@error_handler +async def apply_recommended_setting( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + parts = callback.data.split(":", 5) + 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: + 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 = _get_setting_metadata(key, definition) + if not metadata.recommended or not metadata.recommended.strip(): + await callback.answer("Для этой настройки нет рекомендуемого значения", show_alert=True) + return + + try: + new_value = bot_configuration_service.parse_user_value(key, metadata.recommended) + except ValueError as error: + await callback.answer(f"Не удалось применить рекомендуемое: {error}", show_alert=True) + return + + old_value = bot_configuration_service.get_current_value(key) + await bot_configuration_service.set_value(db, key, new_value) + await db.commit() + _log_setting_change(key, old_value, new_value, "recommended") + + 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 _store_setting_context( + state, + key=key, + group_key=group_key, + category_page=category_page, + settings_page=settings_page, + ) + await callback.answer("Рекомендация применена") + + +async def _start_list_operation( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + *, + operation: str, +) -> None: + parts = callback.data.split(":", 5) + 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: + 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 + + current_items = _to_list(bot_configuration_service.get_current_value(key)) + + if operation == "remove" and not current_items: + await callback.answer("Список пуст — нечего удалять", show_alert=True) + return + + await _store_setting_context( + state, + key=key, + group_key=group_key, + category_page=category_page, + settings_page=settings_page, + ) + await state.update_data(list_operation=operation) + await state.set_state(BotConfigStates.waiting_for_list_input) + + if operation == "add": + prompt = ( + "➕ Добавление значения\n\n" + "Введите элемент, который нужно добавить в список." + ) + else: + items_preview = "\n".join(f"• {item}" for item in current_items[:20]) + prompt = ( + "➖ Удаление значения\n\n" + "Текущий список:\n" + f"{items_preview or '—'}\n\nВведите элемент, который нужно удалить." + ) + + await callback.message.answer(prompt, parse_mode="HTML") + await callback.answer() + + +@admin_required +@error_handler +async def start_list_add( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_list_operation(callback, db_user, state, operation="add") + + +@admin_required +@error_handler +async def start_list_remove( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_list_operation(callback, db_user, state, operation="remove") + + def register_handlers(dp: Dispatcher) -> None: dp.callback_query.register( show_bot_config_menu, @@ -1407,6 +3175,58 @@ def register_handlers(dp: Dispatcher) -> None: apply_setting_choice, F.data.startswith("botcfg_choice:"), ) + dp.callback_query.register( + set_boolean_setting, + F.data.startswith("botcfg_bool:"), + ) + dp.callback_query.register( + apply_recommended_setting, + F.data.startswith("botcfg_apply_rec:"), + ) + dp.callback_query.register( + start_list_add, + F.data.startswith("botcfg_list_add:"), + ) + dp.callback_query.register( + start_list_remove, + F.data.startswith("botcfg_list_remove:"), + ) + dp.callback_query.register( + start_copy_setting, + F.data.startswith("botcfg_copy:"), + ) + dp.callback_query.register( + start_settings_search, + F.data == "botcfg_search", + ) + dp.callback_query.register( + show_history, + F.data == "botcfg_history", + ) + dp.callback_query.register( + show_presets_menu, + F.data == "botcfg_presets", + ) + dp.callback_query.register( + apply_preset, + F.data.startswith("botcfg_apply_preset:"), + ) + dp.callback_query.register( + start_save_preset, + F.data == "botcfg_save_preset", + ) + dp.callback_query.register( + export_settings, + F.data == "botcfg_export", + ) + dp.callback_query.register( + start_import_settings, + F.data == "botcfg_import", + ) + dp.callback_query.register( + show_help, + F.data == "botcfg_help", + ) dp.message.register( handle_direct_setting_input, StateFilter(None), @@ -1417,4 +3237,24 @@ def register_handlers(dp: Dispatcher) -> None: handle_edit_setting, BotConfigStates.waiting_for_value, ) + dp.message.register( + handle_copy_source, + BotConfigStates.waiting_for_copy_source, + ) + dp.message.register( + handle_search_query, + BotConfigStates.waiting_for_search_query, + ) + dp.message.register( + handle_preset_name, + BotConfigStates.waiting_for_preset_name, + ) + dp.message.register( + handle_import_settings, + BotConfigStates.waiting_for_import, + ) + dp.message.register( + handle_list_input, + BotConfigStates.waiting_for_list_input, + ) diff --git a/app/states.py b/app/states.py index 43655b35..147a9187 100644 --- a/app/states.py +++ b/app/states.py @@ -132,6 +132,11 @@ class SupportSettingsStates(StatesGroup): class BotConfigStates(StatesGroup): waiting_for_value = State() + waiting_for_search_query = State() + waiting_for_import = State() + waiting_for_list_input = State() + waiting_for_copy_source = State() + waiting_for_preset_name = State() class AutoPayStates(StatesGroup): setting_autopay_days = State()