import hashlib import json from dataclasses import dataclass from typing import Any, Optional, Union, get_args, get_origin import structlog from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import ( ENV_OVERRIDE_KEYS, Settings, refresh_period_prices, refresh_traffic_prices, settings, ) from app.database.crud.system_setting import ( delete_system_setting, upsert_system_setting, ) from app.database.database import AsyncSessionLocal from app.database.models import SystemSetting from app.services.web_api_token_service import ensure_default_web_api_token logger = structlog.get_logger(__name__) def _title_from_key(key: str) -> str: parts = key.split('_') if not parts: return key return ' '.join(part.capitalize() for part in parts) def _truncate(value: str, max_len: int = 60) -> str: value = value.strip() if len(value) <= max_len: return value return value[: max_len - 1] + '…' @dataclass(slots=True) class SettingDefinition: key: str category_key: str category_label: str python_type: type[Any] type_label: str is_optional: bool @property def display_name(self) -> str: return _title_from_key(self.key) @dataclass(slots=True) class ChoiceOption: value: Any label: str description: str | None = None class ReadOnlySettingError(RuntimeError): """Исключение, выбрасываемое при попытке изменить настройку только для чтения.""" class BotConfigurationService: EXCLUDED_KEYS: set[str] = {'BOT_TOKEN', 'ADMIN_IDS'} READ_ONLY_KEYS: set[str] = {'EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'} PLAIN_TEXT_KEYS: set[str] = {'EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'} CATEGORY_TITLES: dict[str, str] = { 'CORE': '🤖 Основные настройки', 'SUPPORT': '💬 Поддержка и тикеты', 'LOCALIZATION': '🌍 Языки интерфейса', 'CHANNEL': '📣 Обязательная подписка', 'TIMEZONE': '🗂 Timezone', 'PAYMENT': '💳 Общие платежные настройки', 'PAYMENT_VERIFICATION': '🕵️ Проверка платежей', 'TELEGRAM': '⭐ Telegram Stars', 'CRYPTOBOT': '🪙 CryptoBot', 'HELEKET': '🪙 Heleket', 'CLOUDPAYMENTS': '💳 CloudPayments', 'FREEKASSA': '💳 Freekassa', 'KASSA_AI': '💳 KassaAI', 'YOOKASSA': '🟣 YooKassa', 'PLATEGA': '💳 {platega_name}', 'TRIBUTE': '🎁 Tribute', 'MULENPAY': '💰 {mulenpay_name}', 'PAL24': '🏦 PAL24 / PayPalych', 'WATA': '💠 Wata', 'EXTERNAL_ADMIN': '🛡️ Внешняя админка', 'SUBSCRIPTIONS_CORE': '📅 Подписки и лимиты', 'SIMPLE_SUBSCRIPTION': '⚡ Простая покупка', 'PERIODS': '📆 Периоды подписок', 'SUBSCRIPTION_PRICES': '💵 Стоимость тарифов', 'TRAFFIC': '📊 Трафик', 'TRAFFIC_PACKAGES': '📦 Пакеты трафика', 'TRIAL': '🎁 Пробный период', 'REFERRAL': '👥 Реферальная программа', 'AUTOPAY': '🔄 Автопродление', 'NOTIFICATIONS': '🔔 Уведомления пользователям', 'ADMIN_NOTIFICATIONS': '📣 Оповещения администраторам', 'ADMIN_REPORTS': '🗂 Автоматические отчеты', 'INTERFACE': '🎨 Интерфейс и брендинг', 'INTERFACE_BRANDING': '🖼️ Брендинг', 'INTERFACE_SUBSCRIPTION': '🔗 Ссылка на подписку', 'CONNECT_BUTTON': '🚀 Кнопка подключения', 'MINIAPP': '📱 Mini App', 'HAPP': '🅷 Happ', 'SKIP': '⚡ Быстрый старт', 'ADDITIONAL': '📱 Дополнительные приложения', 'DATABASE': '💾 База данных', 'POSTGRES': '🐘 PostgreSQL', 'SQLITE': '🧱 SQLite', 'REDIS': '🧠 Redis', 'REMNAWAVE': '🌐 RemnaWave API', 'SERVER_STATUS': '📊 Статус серверов', 'MONITORING': '📈 Мониторинг', 'MAINTENANCE': '🔧 Обслуживание', 'BACKUP': '💾 Резервные копии', 'VERSION': '🔄 Проверка версий', 'WEB_API': '⚡ Web API', 'WEBHOOK': '🌐 Webhook', 'WEBHOOK_NOTIFICATIONS': '📢 Уведомления от вебхуков', 'LOG': '📝 Логирование', 'DEBUG': '🧪 Режим разработки', 'MODERATION': '🛡️ Модерация и фильтры', 'BAN_NOTIFICATIONS': '🚫 Тексты уведомлений о блокировках', } CATEGORY_DESCRIPTIONS: dict[str, str] = { 'CORE': 'Базовые параметры работы бота и обязательные ссылки.', 'SUPPORT': 'Контакты поддержки, SLA и режимы обработки обращений.', 'LOCALIZATION': 'Доступные языки, локализация интерфейса и выбор языка.', 'CHANNEL': 'Настройки обязательной подписки на канал или группу.', 'TIMEZONE': 'Часовой пояс панели и отображение времени.', 'PAYMENT': 'Общие тексты платежей, описания чеков и шаблоны.', 'PAYMENT_VERIFICATION': 'Автоматическая проверка пополнений и интервал выполнения.', 'YOOKASSA': 'Интеграция с YooKassa: идентификаторы магазина и вебхуки.', 'CRYPTOBOT': 'CryptoBot и криптоплатежи через Telegram.', 'HELEKET': 'Heleket: криптоплатежи, ключи мерчанта и вебхуки.', 'CLOUDPAYMENTS': 'CloudPayments: оплата банковскими картами, Public ID, API Secret и вебхуки.', 'FREEKASSA': 'Freekassa: ID магазина, API ключ, секретные слова и вебхуки.', 'KASSA_AI': 'KassaAI: отдельная платёжка api.fk.life с СБП, картами и SberPay.', 'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.', 'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.', 'PAL24': 'PAL24 / PayPalych подключения и лимиты.', 'TRIBUTE': 'Tribute и донат-сервисы.', 'TELEGRAM': 'Telegram Stars и их стоимость.', 'WATA': 'Wata: токен доступа, тип платежа и пределы сумм.', 'EXTERNAL_ADMIN': 'Токен внешней админки для проверки запросов.', 'SUBSCRIPTIONS_CORE': 'Лимиты устройств, трафика и базовые цены подписок.', 'SIMPLE_SUBSCRIPTION': 'Параметры упрощённой покупки: период, трафик, устройства и сквады.', 'PERIODS': 'Доступные периоды подписок и продлений.', 'SUBSCRIPTION_PRICES': 'Стоимость подписок по периодам в копейках.', 'TRAFFIC': 'Лимиты трафика и стратегии сброса.', 'TRAFFIC_PACKAGES': 'Цены пакетов трафика и конфигурация предложений.', 'TRIAL': 'Длительность и ограничения пробного периода.', 'REFERRAL': 'Бонусы и пороги реферальной программы.', 'AUTOPAY': 'Настройки автопродления и минимальный баланс.', 'NOTIFICATIONS': 'Пользовательские уведомления и кэширование сообщений.', 'ADMIN_NOTIFICATIONS': 'Оповещения админам о событиях и тикетах.', 'ADMIN_REPORTS': 'Автоматические отчеты для команды.', 'INTERFACE': 'Глобальные параметры интерфейса и брендирования.', 'INTERFACE_BRANDING': 'Логотип и фирменный стиль.', 'INTERFACE_SUBSCRIPTION': 'Отображение ссылок и кнопок подписок.', 'CONNECT_BUTTON': 'Поведение кнопки «Подключиться» и miniapp.', 'MINIAPP': 'Mini App и кастомные ссылки.', 'HAPP': 'Интеграция Happ и связанные ссылки.', 'SKIP': 'Настройки быстрого старта и гайд по подключению.', 'ADDITIONAL': 'Конфигурация app-config.json, deep links и кеша.', 'DATABASE': 'Режим работы базы данных и пути до файлов.', 'POSTGRES': 'Параметры подключения к PostgreSQL.', 'SQLITE': 'Файл SQLite и резервные параметры.', 'REDIS': 'Подключение к Redis для кэша.', 'REMNAWAVE': 'Параметры авторизации и интеграция с RemnaWave API.', 'SERVER_STATUS': 'Отображение статуса серверов и external URL.', 'MONITORING': 'Интервалы мониторинга и хранение логов.', 'MAINTENANCE': 'Режим обслуживания, сообщения и интервалы.', 'BACKUP': 'Резервное копирование и расписание.', 'VERSION': 'Отслеживание обновлений репозитория.', 'WEB_API': 'Web API, токены и права доступа.', 'WEBHOOK': 'Пути и секреты вебхуков.', 'WEBHOOK_NOTIFICATIONS': 'Управление уведомлениями, которые получают пользователи при событиях RemnaWave (отключение/активация подписки, устройства, трафик и т.д.).', 'LOG': 'Уровни логирования и ротация.', 'DEBUG': 'Отладочные функции и безопасный режим.', 'MODERATION': 'Настройки фильтров отображаемых имен и защиты от фишинга.', 'BAN_NOTIFICATIONS': 'Тексты уведомлений о блокировках, которые отправляются пользователям.', } @staticmethod def _format_dynamic_copy(category_key: str | None, value: str) -> str: if not value: return value if category_key == 'MULENPAY': return value.format(mulenpay_name=settings.get_mulenpay_display_name()) if category_key == 'PLATEGA': return value.format(platega_name=settings.get_platega_display_name()) return value CATEGORY_KEY_OVERRIDES: dict[str, str] = { 'DATABASE_URL': 'DATABASE', 'DATABASE_MODE': 'DATABASE', 'LOCALES_PATH': 'LOCALIZATION', 'CHANNEL_SUB_ID': 'CHANNEL', 'CHANNEL_LINK': 'CHANNEL', 'CHANNEL_IS_REQUIRED_SUB': 'CHANNEL', 'BOT_USERNAME': 'CORE', 'DEFAULT_LANGUAGE': 'LOCALIZATION', 'AVAILABLE_LANGUAGES': 'LOCALIZATION', 'LANGUAGE_SELECTION_ENABLED': 'LOCALIZATION', 'DEFAULT_DEVICE_LIMIT': 'SUBSCRIPTIONS_CORE', 'DEFAULT_TRAFFIC_LIMIT_GB': 'SUBSCRIPTIONS_CORE', 'MAX_DEVICES_LIMIT': 'SUBSCRIPTIONS_CORE', 'PRICE_PER_DEVICE': 'SUBSCRIPTIONS_CORE', 'DEVICES_SELECTION_ENABLED': 'SUBSCRIPTIONS_CORE', 'DEVICES_SELECTION_DISABLED_AMOUNT': 'SUBSCRIPTIONS_CORE', 'BASE_SUBSCRIPTION_PRICE': 'SUBSCRIPTIONS_CORE', 'SALES_MODE': 'SUBSCRIPTIONS_CORE', 'DEFAULT_TRAFFIC_RESET_STRATEGY': 'TRAFFIC', 'RESET_TRAFFIC_ON_PAYMENT': 'TRAFFIC', 'TRAFFIC_SELECTION_MODE': 'TRAFFIC', 'FIXED_TRAFFIC_LIMIT_GB': 'TRAFFIC', 'AVAILABLE_SUBSCRIPTION_PERIODS': 'PERIODS', 'AVAILABLE_RENEWAL_PERIODS': 'PERIODS', 'PRICE_14_DAYS': 'SUBSCRIPTION_PRICES', 'PRICE_30_DAYS': 'SUBSCRIPTION_PRICES', 'PRICE_60_DAYS': 'SUBSCRIPTION_PRICES', 'PRICE_90_DAYS': 'SUBSCRIPTION_PRICES', 'PRICE_180_DAYS': 'SUBSCRIPTION_PRICES', 'PRICE_360_DAYS': 'SUBSCRIPTION_PRICES', 'PAID_SUBSCRIPTION_USER_TAG': 'SUBSCRIPTION_PRICES', 'TRAFFIC_PACKAGES_CONFIG': 'TRAFFIC_PACKAGES', 'BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED': 'SUBSCRIPTIONS_CORE', 'BASE_PROMO_GROUP_PERIOD_DISCOUNTS': 'SUBSCRIPTIONS_CORE', 'DEFAULT_AUTOPAY_ENABLED': 'AUTOPAY', 'DEFAULT_AUTOPAY_DAYS_BEFORE': 'AUTOPAY', 'MIN_BALANCE_FOR_AUTOPAY_KOPEKS': 'AUTOPAY', 'TRIAL_WARNING_HOURS': 'TRIAL', 'TRIAL_USER_TAG': 'TRIAL', 'SUPPORT_USERNAME': 'SUPPORT', 'SUPPORT_MENU_ENABLED': 'SUPPORT', 'SUPPORT_SYSTEM_MODE': 'SUPPORT', 'SUPPORT_TICKET_SLA_ENABLED': 'SUPPORT', 'SUPPORT_TICKET_SLA_MINUTES': 'SUPPORT', 'SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS': 'SUPPORT', 'SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES': 'SUPPORT', 'ADMIN_NOTIFICATIONS_ENABLED': 'ADMIN_NOTIFICATIONS', 'ADMIN_NOTIFICATIONS_CHAT_ID': 'ADMIN_NOTIFICATIONS', 'ADMIN_NOTIFICATIONS_TOPIC_ID': 'ADMIN_NOTIFICATIONS', 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID': 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS_ENABLED': 'ADMIN_REPORTS', 'ADMIN_REPORTS_CHAT_ID': 'ADMIN_REPORTS', 'ADMIN_REPORTS_TOPIC_ID': 'ADMIN_REPORTS', 'ADMIN_REPORTS_SEND_TIME': 'ADMIN_REPORTS', 'PAYMENT_SERVICE_NAME': 'PAYMENT', 'PAYMENT_BALANCE_DESCRIPTION': 'PAYMENT', 'PAYMENT_SUBSCRIPTION_DESCRIPTION': 'PAYMENT', 'PAYMENT_BALANCE_TEMPLATE': 'PAYMENT', 'PAYMENT_SUBSCRIPTION_TEMPLATE': 'PAYMENT', 'AUTO_PURCHASE_AFTER_TOPUP_ENABLED': 'PAYMENT', 'SIMPLE_SUBSCRIPTION_ENABLED': 'SIMPLE_SUBSCRIPTION', 'SIMPLE_SUBSCRIPTION_PERIOD_DAYS': 'SIMPLE_SUBSCRIPTION', 'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT': 'SIMPLE_SUBSCRIPTION', 'SIMPLE_SUBSCRIPTION_TRAFFIC_GB': 'SIMPLE_SUBSCRIPTION', 'SIMPLE_SUBSCRIPTION_SQUAD_UUID': 'SIMPLE_SUBSCRIPTION', 'DISABLE_TOPUP_BUTTONS': 'PAYMENT', 'SUPPORT_TOPUP_ENABLED': 'PAYMENT', 'ENABLE_NOTIFICATIONS': 'NOTIFICATIONS', 'NOTIFICATION_RETRY_ATTEMPTS': 'NOTIFICATIONS', 'NOTIFICATION_CACHE_HOURS': 'NOTIFICATIONS', 'MONITORING_LOGS_RETENTION_DAYS': 'MONITORING', 'MONITORING_INTERVAL': 'MONITORING', 'TRAFFIC_MONITORING_ENABLED': 'MONITORING', 'TRAFFIC_MONITORING_INTERVAL_HOURS': 'MONITORING', 'TRAFFIC_MONITORED_NODES': 'MONITORING', 'TRAFFIC_SNAPSHOT_TTL_HOURS': 'MONITORING', 'TRAFFIC_FAST_CHECK_ENABLED': 'MONITORING', 'TRAFFIC_FAST_CHECK_INTERVAL_MINUTES': 'MONITORING', 'TRAFFIC_FAST_CHECK_THRESHOLD_GB': 'MONITORING', 'TRAFFIC_DAILY_CHECK_ENABLED': 'MONITORING', 'TRAFFIC_DAILY_CHECK_TIME': 'MONITORING', 'TRAFFIC_DAILY_THRESHOLD_GB': 'MONITORING', 'TRAFFIC_IGNORED_NODES': 'MONITORING', 'TRAFFIC_EXCLUDED_USER_UUIDS': 'MONITORING', 'TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES': 'MONITORING', 'SUSPICIOUS_NOTIFICATIONS_TOPIC_ID': 'MONITORING', 'TRAFFIC_CHECK_BATCH_SIZE': 'MONITORING', 'TRAFFIC_CHECK_CONCURRENCY': 'MONITORING', 'ENABLE_LOGO_MODE': 'INTERFACE_BRANDING', 'LOGO_FILE': 'INTERFACE_BRANDING', 'HIDE_SUBSCRIPTION_LINK': 'INTERFACE_SUBSCRIPTION', 'MAIN_MENU_MODE': 'INTERFACE', 'CABINET_BUTTON_STYLE': 'INTERFACE', 'CONNECT_BUTTON_MODE': 'CONNECT_BUTTON', 'MINIAPP_CUSTOM_URL': 'CONNECT_BUTTON', 'APP_CONFIG_PATH': 'ADDITIONAL', 'ENABLE_DEEP_LINKS': 'ADDITIONAL', 'APP_CONFIG_CACHE_TTL': 'ADDITIONAL', 'INACTIVE_USER_DELETE_MONTHS': 'MAINTENANCE', 'MAINTENANCE_MESSAGE': 'MAINTENANCE', 'MAINTENANCE_CHECK_INTERVAL': 'MAINTENANCE', 'MAINTENANCE_AUTO_ENABLE': 'MAINTENANCE', 'MAINTENANCE_RETRY_ATTEMPTS': 'MAINTENANCE', 'WEBHOOK_URL': 'WEBHOOK', 'WEBHOOK_SECRET': 'WEBHOOK', 'VERSION_CHECK_ENABLED': 'VERSION', 'VERSION_CHECK_REPO': 'VERSION', 'VERSION_CHECK_INTERVAL_HOURS': 'VERSION', 'TELEGRAM_STARS_RATE_RUB': 'TELEGRAM', 'REMNAWAVE_USER_DESCRIPTION_TEMPLATE': 'REMNAWAVE', 'REMNAWAVE_USER_USERNAME_TEMPLATE': 'REMNAWAVE', 'REMNAWAVE_AUTO_SYNC_ENABLED': 'REMNAWAVE', 'REMNAWAVE_AUTO_SYNC_TIMES': 'REMNAWAVE', 'CABINET_REMNA_SUB_CONFIG': 'MINIAPP', } CATEGORY_PREFIX_OVERRIDES: dict[str, str] = { 'SUPPORT_': 'SUPPORT', 'ADMIN_NOTIFICATIONS': 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS': 'ADMIN_REPORTS', 'CHANNEL_': 'CHANNEL', 'POSTGRES_': 'POSTGRES', 'SQLITE_': 'SQLITE', 'REDIS_': 'REDIS', 'REMNAWAVE': 'REMNAWAVE', 'TRIAL_': 'TRIAL', 'TRAFFIC_PACKAGES': 'TRAFFIC_PACKAGES', 'PRICE_TRAFFIC': 'TRAFFIC_PACKAGES', 'TRAFFIC_': 'TRAFFIC', 'REFERRAL_': 'REFERRAL', 'AUTOPAY_': 'AUTOPAY', 'TELEGRAM_STARS': 'TELEGRAM', 'TRIBUTE_': 'TRIBUTE', 'YOOKASSA_': 'YOOKASSA', 'CRYPTOBOT_': 'CRYPTOBOT', 'HELEKET_': 'HELEKET', 'CLOUDPAYMENTS_': 'CLOUDPAYMENTS', 'FREEKASSA_': 'FREEKASSA', 'KASSA_AI_': 'KASSA_AI', 'PLATEGA_': 'PLATEGA', 'MULENPAY_': 'MULENPAY', 'PAL24_': 'PAL24', 'PAYMENT_': 'PAYMENT', 'PAYMENT_VERIFICATION_': 'PAYMENT_VERIFICATION', 'WATA_': 'WATA', 'EXTERNAL_ADMIN_': 'EXTERNAL_ADMIN', 'SIMPLE_SUBSCRIPTION_': 'SIMPLE_SUBSCRIPTION', 'CONNECT_BUTTON_HAPP': 'HAPP', 'HAPP_': 'HAPP', 'SKIP_': 'SKIP', 'MINIAPP_': 'MINIAPP', 'MONITORING_': 'MONITORING', 'NOTIFICATION_': 'NOTIFICATIONS', 'SERVER_STATUS': 'SERVER_STATUS', 'MAINTENANCE_': 'MAINTENANCE', 'VERSION_CHECK': 'VERSION', 'BACKUP_': 'BACKUP', 'WEBHOOK_NOTIFY_': 'WEBHOOK_NOTIFICATIONS', 'WEBHOOK_': 'WEBHOOK', 'LOG_': 'LOG', 'WEB_API_': 'WEB_API', 'DEBUG': 'DEBUG', 'DISPLAY_NAME_': 'MODERATION', 'BAN_MSG_': 'BAN_NOTIFICATIONS', } CHOICES: dict[str, list[ChoiceOption]] = { 'DATABASE_MODE': [ ChoiceOption('auto', '🤖 Авто'), ChoiceOption('postgresql', '🐘 PostgreSQL'), ChoiceOption('sqlite', '💾 SQLite'), ], 'REMNAWAVE_AUTH_TYPE': [ ChoiceOption('api_key', '🔑 API Key'), ChoiceOption('basic_auth', '🧾 Basic Auth'), ], 'REMNAWAVE_USER_DELETE_MODE': [ ChoiceOption('delete', '🗑 Удалять'), ChoiceOption('disable', '🚫 Деактивировать'), ], 'TRAFFIC_SELECTION_MODE': [ ChoiceOption('selectable', '📦 Выбор пакетов'), ChoiceOption('fixed', '📏 Фиксированный лимит'), ChoiceOption('fixed_with_topup', '📏 Фикс. лимит + докупка'), ], 'DEFAULT_TRAFFIC_RESET_STRATEGY': [ ChoiceOption('NO_RESET', '♾️ Без сброса'), ChoiceOption('DAY', '📅 Ежедневно'), ChoiceOption('WEEK', '🗓 Еженедельно'), ChoiceOption('MONTH', '📆 Ежемесячно'), ], 'SUPPORT_SYSTEM_MODE': [ ChoiceOption('tickets', '🎫 Только тикеты'), ChoiceOption('contact', '💬 Только контакт'), ChoiceOption('both', '🔁 Оба варианта'), ], 'CONNECT_BUTTON_MODE': [ ChoiceOption('guide', '📘 Гайд'), ChoiceOption('miniapp_subscription', '🧾 Mini App подписка'), ChoiceOption('miniapp_custom', '🧩 Mini App (ссылка)'), ChoiceOption('link', '🔗 Прямая ссылка'), ChoiceOption('happ_cryptolink', '🪙 Happ CryptoLink'), ], 'MAIN_MENU_MODE': [ ChoiceOption('default', '📋 Полное меню'), ChoiceOption('cabinet', '🏠 Cabinet (МиниАпп)'), ], 'CABINET_BUTTON_STYLE': [ ChoiceOption('', '🎨 По секциям (авто)'), ChoiceOption('primary', '🔵 Синий'), ChoiceOption('success', '🟢 Зелёный'), ChoiceOption('danger', '🔴 Красный'), ], 'SALES_MODE': [ ChoiceOption('classic', '📋 Классический (периоды из .env)'), ChoiceOption('tariffs', '📦 Тарифы (из кабинета)'), ], 'SERVER_STATUS_MODE': [ ChoiceOption('disabled', '🚫 Отключено'), ChoiceOption('external_link', '🌐 Внешняя ссылка'), ChoiceOption('external_link_miniapp', '🧭 Mini App ссылка'), ChoiceOption('xray', '📊 XRay Checker'), ], 'YOOKASSA_PAYMENT_MODE': [ ChoiceOption('full_payment', '💳 Полная оплата'), ChoiceOption('partial_payment', '🪙 Частичная оплата'), ChoiceOption('advance', '💼 Аванс'), ChoiceOption('full_prepayment', '📦 Полная предоплата'), ChoiceOption('partial_prepayment', '📦 Частичная предоплата'), ChoiceOption('credit', '💰 Кредит'), ChoiceOption('credit_payment', '💸 Погашение кредита'), ], 'YOOKASSA_PAYMENT_SUBJECT': [ ChoiceOption('commodity', '📦 Товар'), ChoiceOption('excise', '🥃 Подакцизный товар'), ChoiceOption('job', '🛠 Работа'), ChoiceOption('service', '🧾 Услуга'), ChoiceOption('gambling_bet', '🎲 Ставка'), ChoiceOption('gambling_prize', '🏆 Выигрыш'), ChoiceOption('lottery', '🎫 Лотерея'), ChoiceOption('lottery_prize', '🎁 Приз лотереи'), ChoiceOption('intellectual_activity', '🧠 Интеллектуальная деятельность'), ChoiceOption('payment', '💱 Платеж'), ChoiceOption('agent_commission', '🤝 Комиссия агента'), ChoiceOption('composite', '🧩 Композитный'), ChoiceOption('another', '📄 Другое'), ], 'YOOKASSA_VAT_CODE': [ ChoiceOption(1, '1 — НДС не облагается'), ChoiceOption(2, '2 — НДС 0%'), ChoiceOption(3, '3 — НДС 10%'), ChoiceOption(4, '4 — НДС 20%'), ChoiceOption(5, '5 — НДС 10/110'), ChoiceOption(6, '6 — НДС 20/120'), ChoiceOption(7, '7 — НДС 5%'), ChoiceOption(8, '8 — НДС 7%'), ChoiceOption(9, '9 — НДС 5/105'), ChoiceOption(10, '10 — НДС 7/107'), ChoiceOption(11, '11 — НДС 22%'), ChoiceOption(12, '12 — НДС 22/122'), ], 'MULENPAY_LANGUAGE': [ ChoiceOption('ru', '🇷🇺 Русский'), ChoiceOption('en', '🇬🇧 Английский'), ], 'LOG_LEVEL': [ ChoiceOption('DEBUG', '🐞 Debug'), ChoiceOption('INFO', 'ℹ️ Info'), ChoiceOption('WARNING', '⚠️ Warning'), ChoiceOption('ERROR', '❌ Error'), ChoiceOption('CRITICAL', '🔥 Critical'), ], 'TRIAL_DISABLED_FOR': [ ChoiceOption('none', '✅ Включён для всех'), ChoiceOption('email', '📧 Отключён для Email'), ChoiceOption('telegram', '📱 Отключён для Telegram'), ChoiceOption('all', '🚫 Отключён для всех'), ], } SETTING_HINTS: dict[str, dict[str, str]] = { 'SALES_MODE': { 'description': ( 'Режим продажи подписок. ' '«Классический» — выбор периода из .env (PRICE_14_DAYS и т.д.). ' '«Тарифы» — готовые тарифные планы из кабинета с серверами и лимитами.' ), 'format': 'Выберите один из доступных режимов.', 'example': 'tariffs', 'warning': ( 'При смене режима логика покупки подписки полностью меняется. ' 'В режиме «Тарифы» пользователи выбирают готовый тарифный план.' ), }, 'YOOKASSA_ENABLED': { 'description': ( 'Включает оплату через YooKassa. Требует корректных идентификаторов магазина и секретного ключа.' ), 'format': 'Булево значение: выберите "Включить" или "Выключить".', 'example': 'Включено при полностью настроенной интеграции.', 'warning': 'При включении без Shop ID и Secret Key пользователи увидят ошибки при оплате.', 'dependencies': 'YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY, YOOKASSA_RETURN_URL', }, 'SIMPLE_SUBSCRIPTION_ENABLED': { 'description': 'Показывает в меню пункт с быстрой покупкой подписки.', 'format': 'Булево значение.', 'example': 'true', 'warning': 'Если остались не настроенные параметры, предложение может вести себя некорректно.', }, 'SIMPLE_SUBSCRIPTION_PERIOD_DAYS': { 'description': 'Период подписки, который предлагается при быстрой покупке.', 'format': 'Выберите один из доступных периодов.', 'example': '30 дн. — 990 ₽', 'warning': 'Не забудьте настроить цену периода в блоке «Стоимость тарифов».', }, 'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT': { 'description': 'Сколько устройств получит пользователь вместе с подпиской по быстрой покупке.', 'format': 'Выберите число устройств.', 'example': '2 устройства', 'warning': 'Значение не должно превышать допустимый лимит в настройках подписок.', }, 'SIMPLE_SUBSCRIPTION_TRAFFIC_GB': { 'description': 'Объём трафика, включённый в простую подписку (0 = безлимит).', 'format': 'Выберите пакет трафика.', 'example': 'Безлимит', }, 'SIMPLE_SUBSCRIPTION_SQUAD_UUID': { 'description': ( 'Привязка быстрой подписки к конкретному скваду. Оставьте пустым для любого доступного сервера.' ), 'format': 'Выберите сквад из списка или очистите значение.', 'example': 'd4aa2b8c-9a36-4f31-93a2-6f07dad05fba', 'warning': 'Убедитесь, что выбранный сквад активен и доступен для подписки.', }, 'DEVICES_SELECTION_ENABLED': { 'description': 'Разрешает пользователям выбирать количество устройств при покупке и продлении подписки.', 'format': 'Булево значение.', 'example': 'false', 'warning': 'При отключении пользователи не смогут докупать устройства из интерфейса бота.', }, 'DEVICES_SELECTION_DISABLED_AMOUNT': { 'description': ( 'Лимит устройств, который автоматически назначается, когда выбор количества устройств выключен. ' 'Значение 0 отключает назначение устройств.' ), 'format': 'Целое число от 0 и выше.', 'example': '3', 'warning': 'При 0 RemnaWave не получит лимит устройств, пользователям не показываются цифры в интерфейсе.', }, 'CRYPTOBOT_ENABLED': { 'description': 'Разрешает принимать криптоплатежи через CryptoBot.', 'format': 'Булево значение.', 'example': 'Включите после указания токена API и секрета вебхука.', 'warning': 'Пустой токен или неверный вебхук приведут к отказам платежей.', 'dependencies': 'CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET', }, 'PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED': { 'description': ( 'Запускает фоновую проверку ожидающих пополнений и повторно обращается ' 'к платёжным провайдерам без участия администратора.' ), 'format': 'Булево значение.', 'example': 'Включено, чтобы автоматически перепроверять зависшие платежи.', 'warning': 'Требует активных интеграций YooKassa, {mulenpay_name}, PayPalych, WATA или CryptoBot.', }, 'PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES': { 'description': ('Интервал между автоматическими проверками ожидающих пополнений в минутах.'), 'format': 'Целое число не меньше 1.', 'example': '10', 'warning': 'Слишком малый интервал может привести к частым обращениям к платёжным API.', 'dependencies': 'PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED', }, 'BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED': { 'description': ('Включает применение базовых скидок на периоды подписок в групповых промо.'), 'format': 'Булево значение.', 'example': 'true', 'warning': 'Скидки применяются только если указаны корректные пары периодов и процентов.', }, 'BASE_PROMO_GROUP_PERIOD_DISCOUNTS': { 'description': ('Список скидок для групп: каждая пара задаёт дни периода и процент скидки.'), 'format': 'Через запятую пары вида <дней>:<скидка>.', 'example': '30:10,60:20,90:30,180:50,360:65', 'warning': 'Некорректные записи будут проигнорированы. Процент ограничен 0-100.', }, 'AUTO_PURCHASE_AFTER_TOPUP_ENABLED': { 'description': ( 'При достаточном балансе автоматически оформляет сохранённую подписку сразу после пополнения.' ), 'format': 'Булево значение.', 'example': 'true', 'warning': ('Используйте с осторожностью: средства будут списаны мгновенно, если корзина найдена.'), }, 'SUPPORT_TICKET_SLA_MINUTES': { 'description': 'Лимит времени для ответа модераторов на тикет в минутах.', 'format': 'Целое число от 1 до 1440.', 'example': '5', 'warning': 'Слишком низкое значение может вызвать частые напоминания, слишком высокое — ухудшить SLA.', 'dependencies': 'SUPPORT_TICKET_SLA_ENABLED, SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES', }, 'MAINTENANCE_MODE': { 'description': 'Переводит бота в режим технического обслуживания и скрывает действия для пользователей.', 'format': 'Булево значение.', 'example': 'Включено на время плановых работ.', 'warning': 'Не забудьте отключить после завершения работ, иначе бот останется недоступен.', 'dependencies': 'MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL', }, 'MAINTENANCE_MONITORING_ENABLED': { 'description': ('Управляет автоматическим запуском мониторинга панели Remnawave при старте бота.'), 'format': 'Булево значение.', 'example': 'false', 'warning': ('При отключении мониторинг можно запустить вручную из панели администратора.'), 'dependencies': 'MAINTENANCE_CHECK_INTERVAL', }, 'MAINTENANCE_RETRY_ATTEMPTS': { 'description': ('Сколько раз повторять проверку панели Remnawave перед фиксацией недоступности.'), 'format': 'Целое число не меньше 1.', 'example': '3', 'warning': ( 'Большие значения увеличивают время реакции на реальные сбои, но помогают избежать ложных срабатываний.' ), 'dependencies': 'MAINTENANCE_CHECK_INTERVAL', }, 'DISPLAY_NAME_BANNED_KEYWORDS': { 'description': ( 'Список слов и фрагментов, при наличии которых в отображаемом имени пользователь будет заблокирован.' ), 'format': 'Перечислите ключевые слова через запятую или с новой строки.', 'example': 'support, security, служебн', 'warning': 'Слишком агрессивные фильтры могут блокировать добросовестных пользователей.', 'dependencies': 'Фильтр отображаемых имен', }, 'REMNAWAVE_API_URL': { 'description': 'Базовый адрес панели RemnaWave, с которой синхронизируется бот.', 'format': 'Полный URL вида https://panel.example.com.', 'example': 'https://panel.remnawave.net', 'warning': 'Недоступный адрес приведет к ошибкам при управлении VPN-учетками.', 'dependencies': 'REMNAWAVE_API_KEY или REMNAWAVE_USERNAME/REMNAWAVE_PASSWORD', }, 'REMNAWAVE_AUTO_SYNC_ENABLED': { 'description': 'Автоматически запускает синхронизацию пользователей и серверов с панелью RemnaWave.', 'format': 'Булево значение.', 'example': 'Включено при корректно настроенных API-ключах.', 'warning': 'При включении без расписания синхронизация не будет выполнена.', 'dependencies': 'REMNAWAVE_AUTO_SYNC_TIMES', }, 'REMNAWAVE_AUTO_SYNC_TIMES': { 'description': ('Список времени в формате HH:MM, когда запускается автосинхронизация в течение суток.'), 'format': 'Перечислите время через запятую или с новой строки (например, 03:00, 15:00).', 'example': '03:00, 15:00', 'warning': ( 'Минимальный интервал между запусками не ограничен, но слишком частые синхронизации нагружают панель.' ), 'dependencies': 'REMNAWAVE_AUTO_SYNC_ENABLED', }, 'REMNAWAVE_USER_DESCRIPTION_TEMPLATE': { 'description': ( 'Шаблон текста, который бот передает в поле Description при создании ' 'или обновлении пользователя в панели RemnaWave.' ), 'format': ('Доступные плейсхолдеры: {full_name}, {username}, {username_clean}, {telegram_id}.'), 'example': 'Bot user: {full_name} {username}', 'warning': 'Плейсхолдер {username} автоматически очищается, если у пользователя нет @username.', }, 'REMNAWAVE_USER_USERNAME_TEMPLATE': { 'description': ( 'Шаблон имени пользователя, которое создаётся в панели RemnaWave для телеграм-пользователя.' ), 'format': ('Доступные плейсхолдеры: {full_name}, {username}, {username_clean}, {telegram_id}.'), 'example': 'vpn_{username_clean}_{telegram_id}', 'warning': ( 'Недопустимые символы автоматически заменяются на подчёркивания. ' 'Если результат пустой, используется user_{telegram_id}.' ), }, 'EXTERNAL_ADMIN_TOKEN': { 'description': 'Приватный токен, который использует внешняя админка для проверки запросов.', 'format': 'Значение генерируется автоматически из username бота и его токена и доступно только для чтения.', 'example': 'Генерируется автоматически', 'warning': 'Токен обновится при смене username или токена бота.', 'dependencies': 'Username телеграм-бота, токен бота', }, 'EXTERNAL_ADMIN_TOKEN_BOT_ID': { 'description': 'Идентификатор телеграм-бота, с которым связан токен внешней админки.', 'format': 'Проставляется автоматически после первого запуска и не редактируется вручную.', 'example': '123456789', 'warning': 'Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.', 'dependencies': 'Результат вызова getMe() в Telegram Bot API', }, 'TRIAL_USER_TAG': { 'description': ( 'Тег, который бот передаст пользователю при активации триальной подписки в панели RemnaWave.' ), 'format': 'До 16 символов: заглавные A-Z, цифры и подчёркивание.', 'example': 'TRIAL_USER', 'warning': 'Неверный формат будет проигнорирован при создании пользователя.', 'dependencies': 'Активация триала и включенная интеграция с RemnaWave', }, 'PAID_SUBSCRIPTION_USER_TAG': { 'description': ('Тег, который бот ставит пользователю при покупке платной подписки в панели RemnaWave.'), 'format': 'До 16 символов: заглавные A-Z, цифры и подчёркивание.', 'example': 'PAID_USER', 'warning': 'Если тег не задан или невалиден, существующий тег не будет изменён.', 'dependencies': 'Оплата подписки и интеграция с RemnaWave', }, 'CABINET_REMNA_SUB_CONFIG': { 'description': ( 'UUID конфигурации страницы подписки из RemnaWave. ' 'Позволяет синхронизировать список приложений напрямую из панели.' ), 'format': 'UUID конфигурации из раздела Subscription Page Configs в RemnaWave.', 'example': 'd4aa2b8c-9a36-4f31-93a2-6f07dad05fba', 'warning': 'Убедитесь, что конфигурация существует в панели и содержит нужные приложения.', 'dependencies': 'Настроенное подключение к RemnaWave API', }, 'TRAFFIC_MONITORING_ENABLED': { 'description': ( 'Включает автоматический мониторинг трафика пользователей. ' 'Система отслеживает изменения трафика (дельту) и сохраняет snapshot в Redis. ' 'При превышении порогов отправляются уведомления пользователям и админам.' ), 'format': 'Булево значение.', 'example': 'true', 'warning': ( 'Требует настроенного подключения к Redis. ' 'При включении будет запущен фоновый мониторинг трафика по расписанию.' ), 'dependencies': 'Redis, TRAFFIC_MONITORING_INTERVAL_HOURS, TRAFFIC_SNAPSHOT_TTL_HOURS', }, 'TRAFFIC_MONITORING_INTERVAL_HOURS': { 'description': ( 'Интервал проверки трафика в часах. ' 'Каждые N часов система проверяет трафик всех активных пользователей и сравнивает с предыдущим snapshot.' ), 'format': 'Целое число часов (минимум 1).', 'example': '24', 'warning': ( 'Слишком маленький интервал может создать большую нагрузку на RemnaWave API. ' 'Рекомендуется 24 часа для ежедневного мониторинга.' ), 'dependencies': 'TRAFFIC_MONITORING_ENABLED', }, 'TRAFFIC_MONITORED_NODES': { 'description': ( 'Список UUID нод для мониторинга трафика через запятую. ' 'Если пусто - мониторятся все ноды. ' 'Позволяет ограничить мониторинг только определенными серверами.' ), 'format': 'UUID через запятую или пусто для всех нод.', 'example': 'd4aa2b8c-9a36-4f31-93a2-6f07dad05fba, a1b2c3d4-5678-90ab-cdef-1234567890ab', 'warning': 'UUID должны существовать в RemnaWave, иначе мониторинг не будет работать.', 'dependencies': 'TRAFFIC_MONITORING_ENABLED', }, 'TRAFFIC_SNAPSHOT_TTL_HOURS': { 'description': ( 'Время жизни (TTL) snapshot трафика в Redis в часах. ' 'Snapshot используется для вычисления дельты (изменения трафика) между проверками. ' 'После истечения TTL snapshot удаляется и создается новый.' ), 'format': 'Целое число часов (минимум 1).', 'example': '24', 'warning': ( 'TTL должен быть >= интервала мониторинга. ' 'Если TTL меньше интервала, snapshot будет удален до следующей проверки.' ), 'dependencies': 'TRAFFIC_MONITORING_ENABLED, Redis', }, 'TRAFFIC_FAST_CHECK_ENABLED': { 'description': ( 'Включает быструю проверку трафика. ' 'Система сравнивает текущий трафик со snapshot и уведомляет о превышениях дельты.' ), 'format': 'Булево значение.', 'example': 'true', 'warning': 'Требует Redis для хранения snapshot. При отключении проверки не выполняются.', 'dependencies': 'Redis, TRAFFIC_FAST_CHECK_INTERVAL_MINUTES, TRAFFIC_FAST_CHECK_THRESHOLD_GB', }, 'TRAFFIC_FAST_CHECK_INTERVAL_MINUTES': { 'description': 'Интервал быстрой проверки трафика в минутах.', 'format': 'Целое число минут (минимум 1).', 'example': '10', 'warning': 'Слишком малый интервал создаёт нагрузку на Remnawave API.', 'dependencies': 'TRAFFIC_FAST_CHECK_ENABLED', }, 'TRAFFIC_FAST_CHECK_THRESHOLD_GB': { 'description': 'Порог дельты трафика в ГБ для быстрой проверки. При превышении отправляется уведомление.', 'format': 'Число с плавающей точкой.', 'example': '5.0', 'warning': 'Слишком низкий порог приведёт к частым уведомлениям.', 'dependencies': 'TRAFFIC_FAST_CHECK_ENABLED', }, 'TRAFFIC_DAILY_CHECK_ENABLED': { 'description': 'Включает суточную проверку трафика через bandwidth-stats API.', 'format': 'Булево значение.', 'example': 'true', 'warning': 'Проверка выполняется в указанное время (TRAFFIC_DAILY_CHECK_TIME).', 'dependencies': 'TRAFFIC_DAILY_CHECK_TIME, TRAFFIC_DAILY_THRESHOLD_GB', }, 'TRAFFIC_DAILY_CHECK_TIME': { 'description': 'Время суточной проверки трафика в формате HH:MM (UTC).', 'format': 'Строка времени HH:MM.', 'example': '00:00', 'warning': 'Время указывается в UTC.', 'dependencies': 'TRAFFIC_DAILY_CHECK_ENABLED', }, 'TRAFFIC_DAILY_THRESHOLD_GB': { 'description': 'Порог суточного трафика в ГБ. При превышении за 24 часа отправляется уведомление.', 'format': 'Число с плавающей точкой.', 'example': '50.0', 'warning': 'Учитывается весь трафик за последние 24 часа.', 'dependencies': 'TRAFFIC_DAILY_CHECK_ENABLED', }, 'TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES': { 'description': 'Кулдаун уведомлений по одному пользователю в минутах.', 'format': 'Целое число минут.', 'example': '60', 'warning': 'Защита от спама уведомлениями по одному и тому же пользователю.', }, 'WEBHOOK_NOTIFY_USER_ENABLED': { 'description': ( 'Глобальный переключатель уведомлений пользователям от вебхуков RemnaWave. ' 'При выключении ни одно уведомление не отправляется, независимо от остальных настроек.' ), 'format': 'Булево значение.', 'example': 'true', }, 'WEBHOOK_NOTIFY_SUB_STATUS': { 'description': 'Уведомления об отключении и активации подписки администратором.', 'format': 'Булево значение.', 'example': 'true', }, 'WEBHOOK_NOTIFY_SUB_EXPIRED': { 'description': 'Уведомления об истечении подписки.', 'format': 'Булево значение.', 'example': 'true', }, 'WEBHOOK_NOTIFY_SUB_EXPIRING': { 'description': 'Предупреждения о скором истечении подписки (72ч, 48ч, 24ч до окончания).', 'format': 'Булево значение.', 'example': 'true', }, 'WEBHOOK_NOTIFY_SUB_LIMITED': { 'description': 'Уведомление при достижении лимита трафика.', 'format': 'Булево значение.', 'example': 'true', }, 'WEBHOOK_NOTIFY_TRAFFIC_RESET': { 'description': 'Уведомление о сбросе счётчика трафика.', 'format': 'Булево значение.', 'example': 'true', }, 'WEBHOOK_NOTIFY_SUB_DELETED': { 'description': 'Уведомление при удалении пользователя из панели.', 'format': 'Булево значение.', 'example': 'true', }, 'WEBHOOK_NOTIFY_SUB_REVOKED': { 'description': 'Уведомление при обновлении ключей подписки (revoke).', 'format': 'Булево значение.', 'example': 'true', }, 'WEBHOOK_NOTIFY_FIRST_CONNECTED': { 'description': 'Уведомление при первом подключении к VPN.', 'format': 'Булево значение.', 'example': 'true', }, 'WEBHOOK_NOTIFY_NOT_CONNECTED': { 'description': 'Напоминание, что пользователь ещё не подключился к VPN.', 'format': 'Булево значение.', 'example': 'true', }, 'WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD': { 'description': 'Предупреждение при приближении к лимиту трафика (порог в %).', 'format': 'Булево значение.', 'example': 'true', }, 'WEBHOOK_NOTIFY_DEVICES': { 'description': 'Уведомления о подключении и отключении устройств.', 'format': 'Булево значение.', 'example': 'true', }, } @classmethod def get_category_description(cls, category_key: str) -> str: description = cls.CATEGORY_DESCRIPTIONS.get(category_key, '') return cls._format_dynamic_copy(category_key, description) @classmethod def is_toggle(cls, key: str) -> bool: definition = cls.get_definition(key) return definition.python_type is bool @classmethod def is_read_only(cls, key: str) -> bool: return key in cls.READ_ONLY_KEYS @classmethod def _is_env_override(cls, key: str) -> bool: return key in cls._env_override_keys @classmethod def _format_numeric_with_unit(cls, key: str, value: float) -> str | None: if isinstance(value, bool): return None upper_key = key.upper() if any(suffix in upper_key for suffix in ('PRICE', '_KOPEKS', 'AMOUNT')): try: return settings.format_price(int(value)) except Exception: return f'{value}' if upper_key.endswith('_PERCENT') or 'PERCENT' in upper_key: return f'{value}%' if upper_key.endswith('_HOURS'): return f'{value} ч' if upper_key.endswith('_MINUTES'): return f'{value} мин' if upper_key.endswith('_SECONDS'): return f'{value} сек' if upper_key.endswith('_DAYS'): return f'{value} дн' if upper_key.endswith('_GB'): return f'{value} ГБ' if upper_key.endswith('_MB'): return f'{value} МБ' return None @classmethod def _split_comma_values(cls, text: str) -> list[str] | None: raw = (text or '').strip() if not raw or ',' not in raw: return None parts = [segment.strip() for segment in raw.split(',') if segment.strip()] return parts or None @classmethod def format_value_human(cls, key: str, value: Any) -> str: if key == 'SIMPLE_SUBSCRIPTION_SQUAD_UUID': if value is None: return 'Любой доступный' if isinstance(value, str): cleaned_value = value.strip() if not cleaned_value: return 'Любой доступный' if value is None: return '—' if isinstance(value, bool): return '✅ ВКЛЮЧЕНО' if value else '❌ ВЫКЛЮЧЕНО' if isinstance(value, (int, float)): formatted = cls._format_numeric_with_unit(key, value) return formatted or str(value) if isinstance(value, str): cleaned = value.strip() if not cleaned: return '—' if key in cls.PLAIN_TEXT_KEYS: return cleaned if any(keyword in key.upper() for keyword in ('TOKEN', 'SECRET', 'PASSWORD', 'KEY')): return '••••••••' items = cls._split_comma_values(cleaned) if items: return ', '.join(items) return cleaned if isinstance(value, (list, tuple, set)): return ', '.join(str(item) for item in value) if isinstance(value, dict): try: return json.dumps(value, ensure_ascii=False) except Exception: return str(value) return str(value) @classmethod def get_setting_guidance(cls, key: str) -> dict[str, str]: definition = cls.get_definition(key) original = cls.get_original_value(key) type_label = definition.type_label hints = dict(cls.SETTING_HINTS.get(key, {})) base_description = ( hints.get('description') or f'Параметр {definition.display_name} управляет категорией «{definition.category_label}».' ) base_format = hints.get('format') or ( 'Булево значение (да/нет).' if definition.python_type is bool else 'Введите значение соответствующего типа (число или строку).' ) example = hints.get('example') or (cls.format_value_human(key, original) if original is not None else '—') warning = hints.get('warning') or ('Неверные значения могут привести к некорректной работе бота.') dependencies = hints.get('dependencies') or definition.category_label return { 'description': base_description, 'format': base_format, 'example': example, 'warning': warning, 'dependencies': dependencies, 'type': type_label, } _definitions: dict[str, SettingDefinition] = {} _original_values: dict[str, Any] = settings.model_dump() _overrides_raw: dict[str, str | None] = {} _env_override_keys: set[str] = set(ENV_OVERRIDE_KEYS) _callback_tokens: dict[str, str] = {} _token_to_key: dict[str, str] = {} _choice_tokens: dict[str, dict[Any, str]] = {} _choice_token_lookup: dict[str, dict[str, Any]] = {} @classmethod def initialize_definitions(cls) -> None: if cls._definitions: return for key, field in Settings.model_fields.items(): if key in cls.EXCLUDED_KEYS: continue annotation = field.annotation python_type, is_optional = cls._normalize_type(annotation) type_label = cls._type_to_label(python_type, is_optional) category_key = cls._resolve_category_key(key) category_label = cls.CATEGORY_TITLES.get( category_key, category_key.capitalize() if category_key else 'Прочее', ) category_label = cls._format_dynamic_copy(category_key, category_label) cls._definitions[key] = SettingDefinition( key=key, category_key=category_key or 'other', category_label=category_label, python_type=python_type, type_label=type_label, is_optional=is_optional, ) cls._register_callback_token(key) if key in cls.CHOICES: cls._ensure_choice_tokens(key) @classmethod def _resolve_category_key(cls, key: str) -> str: override = cls.CATEGORY_KEY_OVERRIDES.get(key) if override: return override for prefix, category in sorted( cls.CATEGORY_PREFIX_OVERRIDES.items(), key=lambda item: len(item[0]), reverse=True ): if key.startswith(prefix): return category if '_' not in key: return key.upper() prefix = key.split('_', 1)[0] return prefix.upper() @classmethod def _normalize_type(cls, annotation: Any) -> tuple[type[Any], bool]: if annotation is None: return str, True origin = get_origin(annotation) if origin is Union: args = [arg for arg in get_args(annotation) if arg is not type(None)] if len(args) == 1: nested_type, nested_optional = cls._normalize_type(args[0]) return nested_type, True return str, True if annotation in {int, float, bool, str}: return annotation, False if annotation in {Optional[int], Optional[float], Optional[bool], Optional[str]}: nested = get_args(annotation)[0] return nested, True # Paths, lists, dicts и прочее будем хранить как строки return str, False @classmethod def _type_to_label(cls, python_type: type[Any], is_optional: bool) -> str: base = { bool: 'bool', int: 'int', float: 'float', str: 'str', }.get(python_type, 'str') return f'optional[{base}]' if is_optional else base @classmethod def get_categories(cls) -> list[tuple[str, str, int]]: cls.initialize_definitions() categories: dict[str, list[SettingDefinition]] = {} for definition in cls._definitions.values(): categories.setdefault(definition.category_key, []).append(definition) result: list[tuple[str, str, int]] = [] for category_key, items in categories.items(): label = items[0].category_label result.append((category_key, label, len(items))) result.sort(key=lambda item: item[1]) return result @classmethod def get_settings_for_category(cls, category_key: str) -> list[SettingDefinition]: cls.initialize_definitions() filtered = [definition for definition in cls._definitions.values() if definition.category_key == category_key] filtered.sort(key=lambda definition: definition.key) return filtered @classmethod def get_definition(cls, key: str) -> SettingDefinition: cls.initialize_definitions() return cls._definitions[key] @classmethod def has_override(cls, key: str) -> bool: if cls._is_env_override(key): return False return key in cls._overrides_raw @classmethod def get_current_value(cls, key: str) -> Any: return getattr(settings, key) @classmethod def get_original_value(cls, key: str) -> Any: return cls._original_values.get(key) @classmethod def format_value(cls, value: Any) -> str: if value is None: return '—' if isinstance(value, bool): return '✅ Да' if value else '❌ Нет' if isinstance(value, (int, float)): return str(value) if isinstance(value, (list, dict, tuple, set)): try: return json.dumps(value, ensure_ascii=False) except Exception: return str(value) return str(value) @classmethod def format_value_for_list(cls, key: str) -> str: value = cls.get_current_value(key) formatted = cls.format_value_human(key, value) if formatted == '—': return formatted return _truncate(formatted) @classmethod def get_choice_options(cls, key: str) -> list[ChoiceOption]: cls.initialize_definitions() dynamic = cls._get_dynamic_choice_options(key) if dynamic is not None: cls.CHOICES[key] = dynamic cls._invalidate_choice_cache(key) return dynamic return cls.CHOICES.get(key, []) @classmethod def _invalidate_choice_cache(cls, key: str) -> None: cls._choice_tokens.pop(key, None) cls._choice_token_lookup.pop(key, None) @classmethod def _get_dynamic_choice_options(cls, key: str) -> list[ChoiceOption] | None: if key == 'SIMPLE_SUBSCRIPTION_PERIOD_DAYS': return cls._build_simple_subscription_period_choices() if key == 'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT': return cls._build_simple_subscription_device_choices() if key == 'SIMPLE_SUBSCRIPTION_TRAFFIC_GB': return cls._build_simple_subscription_traffic_choices() return None @staticmethod def _build_simple_subscription_period_choices() -> list[ChoiceOption]: raw_periods = str(getattr(settings, 'AVAILABLE_SUBSCRIPTION_PERIODS', '') or '') period_values: set[int] = set() for segment in raw_periods.split(','): segment = segment.strip() if not segment: continue try: period = int(segment) except ValueError: continue if period > 0: period_values.add(period) fallback_period = getattr(settings, 'SIMPLE_SUBSCRIPTION_PERIOD_DAYS', 30) or 30 try: fallback_period = int(fallback_period) except (TypeError, ValueError): fallback_period = 30 period_values.add(max(1, fallback_period)) options: list[ChoiceOption] = [] for days in sorted(period_values): price_attr = f'PRICE_{days}_DAYS' price_value = getattr(settings, price_attr, None) if not isinstance(price_value, int): price_value = settings.BASE_SUBSCRIPTION_PRICE label = f'{days} дн.' try: if isinstance(price_value, int): label = f'{label} — {settings.format_price(price_value)}' except Exception: logger.debug('Не удалось форматировать цену для периода', days=days, exc_info=True) options.append(ChoiceOption(days, label)) return options @classmethod def _build_simple_subscription_device_choices(cls) -> list[ChoiceOption]: default_limit = getattr(settings, 'DEFAULT_DEVICE_LIMIT', 1) or 1 try: default_limit = int(default_limit) except (TypeError, ValueError): default_limit = 1 max_limit = getattr(settings, 'MAX_DEVICES_LIMIT', default_limit) or default_limit try: max_limit = int(max_limit) except (TypeError, ValueError): max_limit = default_limit current_limit = getattr(settings, 'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT', default_limit) or default_limit try: current_limit = int(current_limit) except (TypeError, ValueError): current_limit = default_limit upper_bound = max(default_limit, max_limit, current_limit, 1) upper_bound = min(max(upper_bound, 1), 50) options: list[ChoiceOption] = [] for count in range(1, upper_bound + 1): label = f'{count} {cls._pluralize_devices(count)}' if count == default_limit: label = f'{label} (по умолчанию)' options.append(ChoiceOption(count, label)) return options @staticmethod def _build_simple_subscription_traffic_choices() -> list[ChoiceOption]: try: packages = settings.get_traffic_packages() except Exception as error: logger.warning('Не удалось получить пакеты трафика', error=error, exc_info=True) packages = [] traffic_values: set[int] = {0} for package in packages: gb_value = package.get('gb') try: gb = int(gb_value) except (TypeError, ValueError): continue if gb >= 0: traffic_values.add(gb) default_limit = getattr(settings, 'DEFAULT_TRAFFIC_LIMIT_GB', 0) or 0 try: default_limit = int(default_limit) except (TypeError, ValueError): default_limit = 0 if default_limit >= 0: traffic_values.add(default_limit) current_limit = getattr(settings, 'SIMPLE_SUBSCRIPTION_TRAFFIC_GB', default_limit) try: current_limit = int(current_limit) except (TypeError, ValueError): current_limit = default_limit if current_limit >= 0: traffic_values.add(current_limit) options: list[ChoiceOption] = [] for gb in sorted(traffic_values): if gb <= 0: label = 'Безлимит' else: label = f'{gb} ГБ' price_label = None for package in packages: try: package_gb = int(package.get('gb')) except (TypeError, ValueError): continue if package_gb != gb: continue price_raw = package.get('price') try: price_value = int(price_raw) if price_value >= 0: price_label = settings.format_price(price_value) except (TypeError, ValueError): continue break if price_label: label = f'{label} — {price_label}' options.append(ChoiceOption(gb, label)) return options @staticmethod def _pluralize_devices(count: int) -> str: count = abs(int(count)) last_two = count % 100 last_one = count % 10 if 11 <= last_two <= 14: return 'устройств' if last_one == 1: return 'устройство' if 2 <= last_one <= 4: return 'устройства' return 'устройств' @classmethod def has_choices(cls, key: str) -> bool: return bool(cls.get_choice_options(key)) @classmethod def get_callback_token(cls, key: str) -> str: cls.initialize_definitions() return cls._callback_tokens[key] @classmethod def resolve_callback_token(cls, token: str) -> str: cls.initialize_definitions() return cls._token_to_key[token] @classmethod def get_choice_token(cls, key: str, value: Any) -> str | None: cls.initialize_definitions() cls._ensure_choice_tokens(key) return cls._choice_tokens.get(key, {}).get(value) @classmethod def resolve_choice_token(cls, key: str, token: str) -> Any: cls.initialize_definitions() cls._ensure_choice_tokens(key) return cls._choice_token_lookup.get(key, {})[token] @classmethod def _register_callback_token(cls, key: str) -> None: if key in cls._callback_tokens: return base = hashlib.blake2s(key.encode('utf-8'), digest_size=6).hexdigest() candidate = base counter = 1 while candidate in cls._token_to_key and cls._token_to_key[candidate] != key: suffix = cls._encode_base36(counter) candidate = f'{base}{suffix}'[:16] counter += 1 cls._callback_tokens[key] = candidate cls._token_to_key[candidate] = key @classmethod def _ensure_choice_tokens(cls, key: str) -> None: if key in cls._choice_tokens: return options = cls.CHOICES.get(key, []) value_to_token: dict[Any, str] = {} token_to_value: dict[str, Any] = {} for index, option in enumerate(options): token = cls._encode_base36(index) value_to_token[option.value] = token token_to_value[token] = option.value cls._choice_tokens[key] = value_to_token cls._choice_token_lookup[key] = token_to_value @staticmethod def _encode_base36(number: int) -> str: if number < 0: raise ValueError('number must be non-negative') alphabet = '0123456789abcdefghijklmnopqrstuvwxyz' if number == 0: return '0' result = [] while number: number, rem = divmod(number, 36) result.append(alphabet[rem]) return ''.join(reversed(result)) @classmethod async def initialize(cls) -> None: cls.initialize_definitions() async with AsyncSessionLocal() as session: result = await session.execute(select(SystemSetting)) rows = result.scalars().all() overrides: dict[str, str | None] = {} for row in rows: if row.key in cls._definitions: overrides[row.key] = row.value for key, raw_value in overrides.items(): if cls._is_env_override(key): logger.debug('Пропускаем настройку из БД: используется значение из окружения', key=key) continue try: parsed_value = cls.deserialize_value(key, raw_value) except Exception as error: logger.error('Не удалось применить настройку', key=key, error=error) continue cls._overrides_raw[key] = raw_value cls._apply_to_settings(key, parsed_value) await cls._sync_default_web_api_token() @classmethod async def reload(cls) -> None: cls._overrides_raw.clear() await cls.initialize() @classmethod def deserialize_value(cls, key: str, raw_value: str | None) -> Any: if raw_value is None: return None definition = cls.get_definition(key) python_type = definition.python_type if python_type is bool: value_lower = raw_value.strip().lower() if value_lower in {'1', 'true', 'on', 'yes', 'да'}: return True if value_lower in {'0', 'false', 'off', 'no', 'нет'}: return False raise ValueError(f'Неверное булево значение: {raw_value}') if python_type is int: return int(raw_value) if python_type is float: return float(raw_value) return raw_value @classmethod def serialize_value(cls, key: str, value: Any) -> str | None: if value is None: return None definition = cls.get_definition(key) python_type = definition.python_type if python_type is bool: return 'true' if value else 'false' if python_type in {int, float}: return str(value) return str(value) @classmethod def parse_user_value(cls, key: str, user_input: str) -> Any: definition = cls.get_definition(key) text = (user_input or '').strip() if text.lower() in {'отмена', 'cancel'}: raise ValueError('Ввод отменен пользователем') if definition.is_optional and text.lower() in {'none', 'null', 'пусто', ''}: return None python_type = definition.python_type if python_type is bool: lowered = text.lower() if lowered in {'1', 'true', 'on', 'yes', 'да', 'вкл', 'enable', 'enabled'}: return True if lowered in {'0', 'false', 'off', 'no', 'нет', 'выкл', 'disable', 'disabled'}: return False raise ValueError("Введите 'true' или 'false' (или 'да'/'нет')") if python_type is int: parsed_value: Any = int(text) elif python_type is float: parsed_value = float(text.replace(',', '.')) else: parsed_value = text choices = cls.get_choice_options(key) if choices: allowed_values = {option.value for option in choices} if python_type is str: lowered_map = {str(option.value).lower(): option.value for option in choices} normalized = lowered_map.get(str(parsed_value).lower()) if normalized is not None: parsed_value = normalized elif parsed_value not in allowed_values: readable = ', '.join(f'{option.label} ({cls.format_value(option.value)})' for option in choices) raise ValueError(f'Доступные значения: {readable}') elif parsed_value not in allowed_values: readable = ', '.join(f'{option.label} ({cls.format_value(option.value)})' for option in choices) raise ValueError(f'Доступные значения: {readable}') return parsed_value @classmethod async def set_value( cls, db: AsyncSession, key: str, value: Any, *, force: bool = False, ) -> None: if cls.is_read_only(key) and not force: raise ReadOnlySettingError(f'Setting {key} is read-only') raw_value = cls.serialize_value(key, value) await upsert_system_setting(db, key, raw_value) if cls._is_env_override(key): logger.info('Настройка сохранена в БД, но не применена: значение задаётся через окружение', key=key) cls._overrides_raw.pop(key, None) else: cls._overrides_raw[key] = raw_value cls._apply_to_settings(key, value) 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, *, force: bool = False, ) -> None: if cls.is_read_only(key) and not force: raise ReadOnlySettingError(f'Setting {key} is read-only') await delete_system_setting(db, key) cls._overrides_raw.pop(key, None) if cls._is_env_override(key): logger.info('Настройка сброшена в БД, используется значение из окружения', key=key) else: original = cls.get_original_value(key) cls._apply_to_settings(key, original) if key in {'WEB_API_DEFAULT_TOKEN', 'WEB_API_DEFAULT_TOKEN_NAME'}: await cls._sync_default_web_api_token() @classmethod def _apply_to_settings(cls, key: str, value: Any) -> None: if cls._is_env_override(key): logger.debug('Пропуск применения настройки : значение задано через окружение', key=key) return try: setattr(settings, key, value) if key in { 'PRICE_14_DAYS', 'PRICE_30_DAYS', 'PRICE_60_DAYS', 'PRICE_90_DAYS', 'PRICE_180_DAYS', 'PRICE_360_DAYS', }: refresh_period_prices() elif key.startswith('PRICE_TRAFFIC_') or key == 'TRAFFIC_PACKAGES_CONFIG': refresh_traffic_prices() elif key in {'REMNAWAVE_AUTO_SYNC_ENABLED', 'REMNAWAVE_AUTO_SYNC_TIMES'}: try: from app.services.remnawave_sync_service import remnawave_sync_service remnawave_sync_service.schedule_refresh( run_immediately=(key == 'REMNAWAVE_AUTO_SYNC_ENABLED' and bool(value)) ) except Exception as error: logger.error('Не удалось обновить сервис автосинхронизации RemnaWave', error=error) elif key == 'SUPPORT_SYSTEM_MODE': try: from app.services.support_settings_service import SupportSettingsService SupportSettingsService.set_system_mode(str(value)) except Exception as error: logger.error('Не удалось синхронизировать SupportSettingsService', error=error) elif key in { 'REMNAWAVE_API_URL', 'REMNAWAVE_API_KEY', 'REMNAWAVE_SECRET_KEY', 'REMNAWAVE_USERNAME', 'REMNAWAVE_PASSWORD', 'REMNAWAVE_AUTH_TYPE', }: try: from app.services.remnawave_sync_service import remnawave_sync_service remnawave_sync_service.refresh_configuration() except Exception as error: logger.error('Не удалось обновить конфигурацию сервиса автосинхронизации RemnaWave', error=error) except Exception as error: logger.error('Не удалось применить значение', key=key, setting_value=value, error=error) @staticmethod async def _sync_default_web_api_token() -> None: default_token = (settings.WEB_API_DEFAULT_TOKEN or '').strip() if not default_token: return success = await ensure_default_web_api_token() if not success: logger.warning( 'Не удалось синхронизировать бутстрап токен веб-API после обновления настроек', ) @classmethod def get_setting_summary(cls, key: str) -> dict[str, Any]: definition = cls.get_definition(key) current = cls.get_current_value(key) original = cls.get_original_value(key) has_override = cls.has_override(key) return { 'key': key, 'name': definition.display_name, 'current': cls.format_value_human(key, current), 'original': cls.format_value_human(key, original), 'type': definition.type_label, 'category_key': definition.category_key, 'category_label': definition.category_label, 'has_override': has_override, 'is_read_only': cls.is_read_only(key), } bot_configuration_service = BotConfigurationService