mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-23 21:01:17 +00:00
Remove AUTO_ACTIVATE_AFTER_TOPUP and SHOW_ACTIVATION_PROMPT_AFTER_TOPUP features from all payment providers, config, system settings, and tests. Cart auto-purchase (AUTO_PURCHASE_AFTER_TOPUP) is preserved. Bug fixes: - fix KeyError 'months' in devices.py for custom locale overrides - fix IntegrityError on trial subscription retry (update existing PENDING instead of INSERT) - fix PendingRollbackError cascade by adding db.rollback() before recovery - fix TelegramForbiddenError not caught in photo_message.py - fix "query is too old" spam in required_sub_channel_check - add missing trial locale keys (TRIAL_PAYMENT_DESCRIPTION, TRIAL_REFUND_DESCRIPTION, TRIAL_ACTIVATION_ERROR)
1601 lines
77 KiB
Python
1601 lines
77 KiB
Python
import hashlib
|
||
import json
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from typing import Any, Optional, Union, get_args, get_origin
|
||
|
||
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.database.universal_migration import ensure_default_web_api_token
|
||
|
||
|
||
logger = logging.getLogger(__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',
|
||
'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': 'Пути и секреты вебхуков.',
|
||
'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',
|
||
'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_': '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('text', '📝 Текстовое меню'),
|
||
],
|
||
'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': 'Защита от спама уведомлениями по одному и тому же пользователю.',
|
||
},
|
||
}
|
||
|
||
@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'Параметр <b>{definition.display_name}</b> управляет категорией «{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('Не удалось форматировать цену для периода %s', 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('Не удалось получить пакеты трафика: %s', 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(
|
||
'Пропускаем настройку %s из БД: используется значение из окружения',
|
||
key,
|
||
)
|
||
continue
|
||
try:
|
||
parsed_value = cls.deserialize_value(key, raw_value)
|
||
except Exception as error:
|
||
logger.error('Не удалось применить настройку %s: %s', key, 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(
|
||
'Настройка %s сохранена в БД, но не применена: значение задаётся через окружение',
|
||
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(
|
||
'Настройка %s сброшена в БД, используется значение из окружения',
|
||
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(
|
||
'Пропуск применения настройки %s: значение задано через окружение',
|
||
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: %s',
|
||
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: %s',
|
||
error,
|
||
)
|
||
except Exception as error:
|
||
logger.error('Не удалось применить значение %s=%s: %s', key, value, 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
|