diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py
index b129ea68..ad0bea38 100644
--- a/app/handlers/admin/bot_configuration.py
+++ b/app/handlers/admin/bot_configuration.py
@@ -1,13 +1,18 @@
+import html
+import io
+import logging
import math
import time
-from typing import Iterable, List, Tuple
+from datetime import datetime
+from typing import Dict, Iterable, List, Optional, Tuple
from aiogram import Dispatcher, F, types
from aiogram.filters import BaseFilter, StateFilter
from aiogram.fsm.context import FSMContext
+from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
-from app.database.models import User
+from app.database.models import SystemSetting, User
from app.localization.texts import get_texts
from app.config import settings
from app.services.remnawave_service import RemnaWaveService
@@ -23,78 +28,946 @@ from app.external.telegram_stars import TelegramStarsService
CATEGORY_PAGE_SIZE = 10
SETTINGS_PAGE_SIZE = 8
+CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
+ "core": {
+ "title": "🤖 Основные",
+ "description": "Базовые настройки бота, обязательные каналы и ключевые сервисы.",
+ "icon": "🤖",
+ "categories": ("CORE", "CHANNEL"),
+ },
+ "support": {
+ "title": "💬 Поддержка",
+ "description": "Контакты, режимы тикетов, SLA и уведомления модераторов.",
+ "icon": "💬",
+ "categories": ("SUPPORT",),
+ },
+ "payments": {
+ "title": "💳 Платежные системы",
+ "description": "YooKassa, CryptoBot, MulenPay, PAL24, Tribute и Telegram Stars.",
+ "icon": "💳",
+ "categories": ("PAYMENT", "YOOKASSA", "CRYPTOBOT", "MULENPAY", "PAL24", "TRIBUTE", "TELEGRAM"),
+ },
+ "subscriptions": {
+ "title": "📅 Подписки и цены",
+ "description": "Тарифы, периоды, лимиты трафика и автопродление.",
+ "icon": "📅",
+ "categories": ("SUBSCRIPTIONS_CORE", "PERIODS", "SUBSCRIPTION_PRICES", "TRAFFIC", "TRAFFIC_PACKAGES", "AUTOPAY"),
+ },
+ "trial": {
+ "title": "🎁 Пробный период",
+ "description": "Длительность и ограничения бесплатного доступа.",
+ "icon": "🎁",
+ "categories": ("TRIAL",),
+ },
+ "referral": {
+ "title": "👥 Реферальная программа",
+ "description": "Бонусы, пороги и уведомления для партнеров.",
+ "icon": "👥",
+ "categories": ("REFERRAL",),
+ },
+ "notifications": {
+ "title": "🔔 Уведомления",
+ "description": "Пользовательские, админские оповещения и отчеты.",
+ "icon": "🔔",
+ "categories": ("NOTIFICATIONS", "ADMIN_NOTIFICATIONS", "ADMIN_REPORTS"),
+ },
+ "interface": {
+ "title": "🎨 Интерфейс и брендинг",
+ "description": "Логотип, тексты, языки, miniapp и deep links.",
+ "icon": "🎨",
+ "categories": ("INTERFACE_BRANDING", "INTERFACE_SUBSCRIPTION", "CONNECT_BUTTON", "MINIAPP", "HAPP", "SKIP", "LOCALIZATION", "ADDITIONAL"),
+ },
+ "database": {
+ "title": "💾 База данных",
+ "description": "Режим базы, параметры PostgreSQL, SQLite и Redis.",
+ "icon": "💾",
+ "categories": ("DATABASE", "POSTGRES", "SQLITE", "REDIS"),
+ },
+ "remnawave": {
+ "title": "🌐 RemnaWave API",
+ "description": "Интеграция с RemnaWave: URL, ключи и способы авторизации.",
+ "icon": "🌐",
+ "categories": ("REMNAWAVE",),
+ },
+ "server": {
+ "title": "📊 Статус серверов",
+ "description": "Мониторинг серверов, SLA и внешние метрики.",
+ "icon": "📊",
+ "categories": ("SERVER_STATUS", "MONITORING"),
+ },
+ "maintenance": {
+ "title": "🔧 Обслуживание",
+ "description": "Режим техработ, бэкапы и проверка обновлений.",
+ "icon": "🔧",
+ "categories": ("MAINTENANCE", "BACKUP", "VERSION"),
+ },
+ "advanced": {
+ "title": "⚡ Расширенные",
+ "description": "Web API, webhook, логирование и режим отладки.",
+ "icon": "⚡",
+ "categories": ("WEB_API", "WEBHOOK", "LOG", "DEBUG"),
+ },
+}
-CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = (
- (
- "core",
- "⚙️ Основные настройки",
- ("SUPPORT", "LOCALIZATION", "MAINTENANCE"),
- ),
- (
- "channels_notifications",
- "📢 Каналы и уведомления",
- ("CHANNEL", "ADMIN_NOTIFICATIONS", "ADMIN_REPORTS"),
- ),
- (
- "subscriptions",
- "💎 Подписки и тарифы",
- ("TRIAL", "PAID_SUBSCRIPTION", "PERIODS", "SUBSCRIPTION_PRICES", "TRAFFIC", "TRAFFIC_PACKAGES", "DISCOUNTS"),
- ),
- (
- "payments",
- "💳 Платежные системы",
- ("PAYMENT", "TELEGRAM", "CRYPTOBOT", "YOOKASSA", "TRIBUTE", "MULENPAY", "PAL24"),
- ),
- (
- "remnawave",
- "🔗 RemnaWave API",
- ("REMNAWAVE",),
- ),
- (
- "referral",
- "🤝 Реферальная система",
- ("REFERRAL",),
- ),
- (
- "autopay",
- "🔄 Автопродление",
- ("AUTOPAY",),
- ),
- (
- "interface",
- "🎨 Интерфейс и UX",
- ("INTERFACE_BRANDING", "INTERFACE_SUBSCRIPTION", "CONNECT_BUTTON", "HAPP", "SKIP", "ADDITIONAL"),
- ),
- (
- "database",
- "🗄️ База данных",
- ("DATABASE", "POSTGRES", "SQLITE", "REDIS"),
- ),
- (
- "monitoring",
- "📊 Мониторинг",
- ("MONITORING", "NOTIFICATIONS", "SERVER"),
- ),
- (
- "backup",
- "💾 Система бэкапов",
- ("BACKUP",),
- ),
- (
- "updates",
- "🔄 Обновления",
- ("VERSION",),
- ),
- (
- "development",
- "🔧 Разработка",
- ("LOG", "WEBHOOK", "WEB_API", "DEBUG"),
- ),
+CATEGORY_GROUP_ORDER: Tuple[str, ...] = (
+ "core",
+ "support",
+ "payments",
+ "subscriptions",
+ "trial",
+ "referral",
+ "notifications",
+ "interface",
+ "database",
+ "remnawave",
+ "server",
+ "maintenance",
+ "advanced",
)
+CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple(
+ (
+ group_key,
+ str(CATEGORY_GROUP_METADATA[group_key]["title"]),
+ tuple(CATEGORY_GROUP_METADATA[group_key]["categories"]),
+ )
+ for group_key in CATEGORY_GROUP_ORDER
+)
+
+CATEGORY_TO_GROUP: Dict[str, str] = {}
+for _group_key, _title, _category_keys in CATEGORY_GROUP_DEFINITIONS:
+ for _category_key in _category_keys:
+ CATEGORY_TO_GROUP[_category_key] = _group_key
+
CATEGORY_FALLBACK_KEY = "other"
CATEGORY_FALLBACK_TITLE = "📦 Прочие настройки"
+PRESET_CONFIGS: Dict[str, Dict[str, object]] = {
+ "recommended": {
+ "ENABLE_NOTIFICATIONS": True,
+ "ADMIN_NOTIFICATIONS_ENABLED": True,
+ "ADMIN_REPORTS_ENABLED": True,
+ "MONITORING_INTERVAL": 60,
+ "TRIAL_DURATION_DAYS": 3,
+ },
+ "minimal": {
+ "ENABLE_NOTIFICATIONS": False,
+ "ADMIN_NOTIFICATIONS_ENABLED": False,
+ "ADMIN_REPORTS_ENABLED": False,
+ "TRIAL_DURATION_DAYS": 0,
+ "REFERRAL_NOTIFICATIONS_ENABLED": False,
+ },
+ "secure": {
+ "MAINTENANCE_AUTO_ENABLE": True,
+ "ADMIN_NOTIFICATIONS_ENABLED": True,
+ "ADMIN_REPORTS_ENABLED": True,
+ "REFERRAL_MINIMUM_TOPUP_KOPEKS": 100000,
+ "SERVER_STATUS_MODE": "disabled",
+ },
+ "testing": {
+ "DEBUG": True,
+ "ENABLE_NOTIFICATIONS": False,
+ "TRIAL_DURATION_DAYS": 7,
+ "SERVER_STATUS_MODE": "disabled",
+ "ADMIN_NOTIFICATIONS_ENABLED": False,
+ },
+}
+
+PRESET_METADATA: Dict[str, Dict[str, str]] = {
+ "recommended": {
+ "title": "Рекомендуемые настройки",
+ "description": "Баланс между стабильностью и информированием команды.",
+ },
+ "minimal": {
+ "title": "Минимальная конфигурация",
+ "description": "Подходит для тестового запуска без уведомлений.",
+ },
+ "secure": {
+ "title": "Максимальная безопасность",
+ "description": "Усиленный контроль доступа и отключение лишних интеграций.",
+ },
+ "testing": {
+ "title": "Для тестирования",
+ "description": "Включает режим отладки и отключает внешние уведомления.",
+ },
+}
+
+
+def _get_group_meta(group_key: str) -> Dict[str, object]:
+ return CATEGORY_GROUP_METADATA.get(group_key, {})
+
+
+def _get_group_description(group_key: str) -> str:
+ meta = _get_group_meta(group_key)
+ return str(meta.get("description", ""))
+
+
+def _get_group_icon(group_key: str) -> str:
+ meta = _get_group_meta(group_key)
+ return str(meta.get("icon", "⚙️"))
+
+
+def _get_group_status(group_key: str) -> Tuple[str, str]:
+ key = group_key
+ if key == "payments":
+ payment_statuses = {
+ "YooKassa": settings.is_yookassa_enabled(),
+ "CryptoBot": settings.is_cryptobot_enabled(),
+ "MulenPay": settings.is_mulenpay_enabled(),
+ "PAL24": settings.is_pal24_enabled(),
+ "Tribute": settings.TRIBUTE_ENABLED,
+ "Stars": settings.TELEGRAM_STARS_ENABLED,
+ }
+ active = sum(1 for value in payment_statuses.values() if value)
+ total = len(payment_statuses)
+ if active == 0:
+ return "🔴", "Нет активных платежей"
+ if active < total:
+ return "🟡", f"Активно {active} из {total}"
+ return "🟢", "Все системы активны"
+
+ if key == "remnawave":
+ api_ready = bool(
+ settings.REMNAWAVE_API_URL
+ and (
+ settings.REMNAWAVE_API_KEY
+ or (settings.REMNAWAVE_USERNAME and settings.REMNAWAVE_PASSWORD)
+ )
+ )
+ return ("🟢", "API подключено") if api_ready else ("🟡", "Нужно указать URL и ключи")
+
+ if key == "server":
+ mode = (settings.SERVER_STATUS_MODE or "").lower()
+ monitoring_active = mode not in {"", "disabled"}
+ if monitoring_active:
+ return "🟢", "Мониторинг активен"
+ if settings.MONITORING_INTERVAL:
+ return "🟡", "Доступны только отчеты"
+ return "⚪", "Мониторинг выключен"
+
+ if key == "maintenance":
+ if settings.MAINTENANCE_MODE:
+ return "🟡", "Режим ТО включен"
+ return "🟢", "Рабочий режим"
+
+ if key == "notifications":
+ user_on = settings.is_notifications_enabled()
+ admin_on = settings.is_admin_notifications_enabled()
+ if user_on and admin_on:
+ return "🟢", "Все уведомления включены"
+ if user_on or admin_on:
+ return "🟡", "Часть уведомлений включена"
+ return "⚪", "Уведомления отключены"
+
+ if key == "trial":
+ if settings.TRIAL_DURATION_DAYS > 0:
+ return "🟢", f"{settings.TRIAL_DURATION_DAYS} дней пробного периода"
+ return "⚪", "Триал отключен"
+
+ if key == "referral":
+ active = (
+ settings.REFERRAL_COMMISSION_PERCENT
+ or settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS
+ or settings.REFERRAL_INVITER_BONUS_KOPEKS
+ or settings.REFERRED_USER_REWARD
+ )
+ return ("🟢", "Программа активна") if active else ("⚪", "Бонусы не заданы")
+
+ if key == "core":
+ token_ok = bool(getattr(settings, "BOT_TOKEN", ""))
+ channel_ok = bool(settings.CHANNEL_LINK or not settings.CHANNEL_IS_REQUIRED_SUB)
+ if token_ok and channel_ok:
+ return "🟢", "Бот готов к работе"
+ return "🟡", "Проверьте токен и обязательную подписку"
+
+ if key == "subscriptions":
+ price_ready = settings.PRICE_30_DAYS > 0 and settings.AVAILABLE_SUBSCRIPTION_PERIODS
+ return ("🟢", "Тарифы настроены") if price_ready else ("⚪", "Нужно задать цены")
+
+ if key == "database":
+ mode = (settings.DATABASE_MODE or "auto").lower()
+ if mode == "postgresql":
+ return "🟢", "PostgreSQL"
+ if mode == "sqlite":
+ return "🟡", "SQLite режим"
+ return "🟢", "Авто режим"
+
+ if key == "interface":
+ branding = bool(settings.ENABLE_LOGO_MODE or settings.MINIAPP_CUSTOM_URL)
+ return ("🟢", "Брендинг настроен") if branding else ("⚪", "Настройки по умолчанию")
+
+ return "🟢", "Готово к работе"
+
+
+def _get_setting_icon(definition, current_value: object) -> str:
+ key_upper = definition.key.upper()
+
+ if definition.python_type is bool:
+ return "✅" if bool(current_value) else "❌"
+
+ if bot_configuration_service.has_choices(definition.key):
+ return "📋"
+
+ if isinstance(current_value, (int, float)):
+ return "🔢"
+
+ if isinstance(current_value, str):
+ if not current_value.strip():
+ return "⚪"
+ if "URL" in key_upper:
+ return "🔗"
+ if any(keyword in key_upper for keyword in ("TOKEN", "SECRET", "PASSWORD", "KEY")):
+ return "🔒"
+
+ if any(keyword in key_upper for keyword in ("TIME", "HOUR", "MINUTE")):
+ return "⏱"
+ if "DAYS" in key_upper:
+ return "📆"
+ if "GB" in key_upper or "TRAFFIC" in key_upper:
+ return "📊"
+
+ return "⚙️"
+
+
+def _render_dashboard_overview() -> str:
+ grouped = _get_grouped_categories()
+ total_settings = 0
+ total_overrides = 0
+
+ for group_key, _title, items in grouped:
+ for category_key, _label, count in items:
+ total_settings += count
+ definitions = bot_configuration_service.get_settings_for_category(category_key)
+ total_overrides += sum(
+ 1 for definition in definitions if bot_configuration_service.has_override(definition.key)
+ )
+
+ lines: List[str] = [
+ "⚙️ Панель управления ботом",
+ "",
+ f"Всего параметров: {total_settings} • Переопределено: {total_overrides}",
+ "",
+ "Выберите категорию ниже или используйте быстрые действия:",
+ "",
+ ]
+
+ for group_key, title, items in grouped:
+ status_icon, status_text = _get_group_status(group_key)
+ description = _get_group_description(group_key) if group_key != CATEGORY_FALLBACK_KEY else "Настройки без категории."
+ total = sum(count for _, _, count in items)
+ lines.append(f"{status_icon} {title} — {status_text}")
+ if description:
+ lines.append(f" {description}")
+ lines.append(f" Настроек: {total}")
+ lines.append("")
+
+ lines.append("🔍 Кнопка поиска поможет найти параметр по названию, описанию или ключу.")
+ return "\n".join(lines).strip()
+
+
+def _build_group_category_index() -> Dict[str, List[str]]:
+ mapping: Dict[str, List[str]] = {}
+ for group_key, _title, items in _get_grouped_categories():
+ mapping[group_key] = [category_key for category_key, _label, _count in items]
+ return mapping
+
+
+def _perform_settings_search(query: str) -> List[Dict[str, object]]:
+ normalized = query.strip().lower()
+ if not normalized:
+ return []
+
+ categories = bot_configuration_service.get_categories()
+ group_category_index = _build_group_category_index()
+ results: List[Dict[str, object]] = []
+
+ for category_key, _label, _count in categories:
+ definitions = bot_configuration_service.get_settings_for_category(category_key)
+ group_key = CATEGORY_TO_GROUP.get(category_key, CATEGORY_FALLBACK_KEY)
+ available_categories = group_category_index.get(group_key, [])
+ if category_key in available_categories:
+ category_index = available_categories.index(category_key)
+ category_page = category_index // CATEGORY_PAGE_SIZE + 1
+ else:
+ category_page = 1
+
+ for definition_index, definition in enumerate(definitions):
+ fields = [definition.key.lower(), definition.display_name.lower()]
+ guidance = bot_configuration_service.get_setting_guidance(definition.key)
+ fields.extend(
+ [
+ guidance.get("description", "").lower(),
+ guidance.get("format", "").lower(),
+ str(guidance.get("dependencies", "")).lower(),
+ ]
+ )
+
+ if not any(normalized in field for field in fields if field):
+ continue
+
+ settings_page = definition_index // SETTINGS_PAGE_SIZE + 1
+ results.append(
+ {
+ "key": definition.key,
+ "name": definition.display_name,
+ "category_key": category_key,
+ "category_label": definition.category_label,
+ "group_key": group_key,
+ "category_page": category_page,
+ "settings_page": settings_page,
+ "token": bot_configuration_service.get_callback_token(definition.key),
+ "value": bot_configuration_service.format_value_human(
+ definition.key,
+ bot_configuration_service.get_current_value(definition.key),
+ ),
+ }
+ )
+
+ results.sort(key=lambda item: item["name"].lower())
+ return results[:20]
+
+
+def _build_search_results_keyboard(results: List[Dict[str, object]]) -> types.InlineKeyboardMarkup:
+ rows: List[List[types.InlineKeyboardButton]] = []
+ for result in results:
+ group_key = str(result["group_key"])
+ category_page = int(result["category_page"])
+ settings_page = int(result["settings_page"])
+ token = str(result["token"])
+ text = f"{result['name']}"
+ if len(text) > 60:
+ text = text[:59] + "…"
+ rows.append(
+ [
+ types.InlineKeyboardButton(
+ text=text,
+ callback_data=(
+ f"botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}"
+ ),
+ )
+ ]
+ )
+
+ rows.append(
+ [
+ types.InlineKeyboardButton(
+ text="⬅️ В главное меню",
+ callback_data="admin_bot_config",
+ )
+ ]
+ )
+ return types.InlineKeyboardMarkup(inline_keyboard=rows)
+
+
+def _parse_env_content(content: str) -> Dict[str, Optional[str]]:
+ parsed: Dict[str, Optional[str]] = {}
+ for raw_line in content.splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#"):
+ continue
+ if "=" not in line:
+ continue
+ key, value = line.split("=", 1)
+ parsed[key.strip()] = value.strip()
+ return parsed
+
+
+@admin_required
+@error_handler
+async def start_settings_search(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ await state.set_state(BotConfigStates.waiting_for_search_query)
+ await state.update_data(botcfg_origin="bot_config")
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text="⬅️ В главное меню", callback_data="admin_bot_config"
+ )
+ ]
+ ]
+ )
+
+ await callback.message.edit_text(
+ "🔍 Поиск по настройкам\n\n"
+ "Отправьте часть ключа или названия настройки. \n"
+ "Например: yookassa или уведомления.",
+ reply_markup=keyboard,
+ parse_mode="HTML",
+ )
+ await callback.answer("Введите запрос", show_alert=False)
+
+
+@admin_required
+@error_handler
+async def handle_search_query(
+ message: types.Message,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ if message.chat.type != "private":
+ return
+
+ data = await state.get_data()
+ if data.get("botcfg_origin") != "bot_config":
+ return
+
+ query = (message.text or "").strip()
+ results = _perform_settings_search(query)
+
+ if results:
+ keyboard = _build_search_results_keyboard(results)
+ lines = [
+ "🔍 Результаты поиска",
+ f"Запрос: {html.escape(query)}",
+ "",
+ ]
+ for index, item in enumerate(results, start=1):
+ lines.append(
+ f"{index}. {item['name']} — {item['value']} ({item['category_label']})"
+ )
+ text = "\n".join(lines)
+ else:
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text="⬅️ Попробовать снова",
+ callback_data="botcfg_action:search",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text="🏠 Главное меню", callback_data="admin_bot_config"
+ )
+ ],
+ ]
+ )
+ text = (
+ "🔍 Результаты поиска\n\n"
+ f"Запрос: {html.escape(query)}\n\n"
+ "Ничего не найдено. Попробуйте изменить формулировку."
+ )
+
+ await message.answer(text, parse_mode="HTML", reply_markup=keyboard)
+ await state.clear()
+
+
+@admin_required
+@error_handler
+async def show_presets(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ lines = [
+ "🎯 Готовые пресеты",
+ "",
+ "Выберите набор параметров, чтобы быстро применить его к боту.",
+ "",
+ ]
+ for key, meta in PRESET_METADATA.items():
+ lines.append(f"• {meta['title']} — {meta['description']}")
+ text = "\n".join(lines)
+
+ buttons: List[types.InlineKeyboardButton] = []
+ for key, meta in PRESET_METADATA.items():
+ buttons.append(
+ types.InlineKeyboardButton(
+ text=meta["title"], callback_data=f"botcfg_preset:{key}"
+ )
+ )
+
+ rows: List[List[types.InlineKeyboardButton]] = []
+ for chunk in _chunk(buttons, 2):
+ rows.append(list(chunk))
+ rows.append(
+ [
+ types.InlineKeyboardButton(
+ text="⬅️ Главное меню", callback_data="admin_bot_config"
+ )
+ ]
+ )
+
+ await callback.message.edit_text(
+ text,
+ parse_mode="HTML",
+ reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows),
+ )
+ await callback.answer()
+
+
+def _format_preset_preview(preset_key: str) -> Tuple[str, List[str]]:
+ config = PRESET_CONFIGS.get(preset_key, {})
+ meta = PRESET_METADATA.get(preset_key, {"title": preset_key, "description": ""})
+ title = meta["title"]
+ description = meta.get("description", "")
+
+ lines = [f"🎯 {title}"]
+ if description:
+ lines.append(description)
+ lines.append("")
+ lines.append("Будут установлены следующие значения:")
+
+ for index, (setting_key, new_value) in enumerate(config.items(), start=1):
+ current_value = bot_configuration_service.get_current_value(setting_key)
+ current_pretty = bot_configuration_service.format_value_human(setting_key, current_value)
+ new_pretty = bot_configuration_service.format_value_human(setting_key, new_value)
+ lines.append(
+ f"{index}. {setting_key}\n"
+ f" Текущее: {current_pretty}\n"
+ f" Новое: {new_pretty}"
+ )
+
+ return title, lines
+
+
+@admin_required
+@error_handler
+async def preview_preset(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ parts = callback.data.split(":", 1)
+ preset_key = parts[1] if len(parts) > 1 else ""
+ if preset_key not in PRESET_CONFIGS:
+ await callback.answer("Этот пресет недоступен", show_alert=True)
+ return
+
+ title, lines = _format_preset_preview(preset_key)
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text="✅ Применить", callback_data=f"botcfg_preset_apply:{preset_key}"
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text="⬅️ Назад", callback_data="botcfg_action:presets"
+ )
+ ],
+ ]
+ )
+
+ await callback.message.edit_text(
+ "\n".join(lines),
+ parse_mode="HTML",
+ reply_markup=keyboard,
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def apply_preset(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ parts = callback.data.split(":", 1)
+ preset_key = parts[1] if len(parts) > 1 else ""
+ config = PRESET_CONFIGS.get(preset_key)
+ if not config:
+ await callback.answer("Этот пресет недоступен", show_alert=True)
+ return
+
+ applied: List[str] = []
+ for setting_key, value in config.items():
+ try:
+ await bot_configuration_service.set_value(db, setting_key, value)
+ applied.append(setting_key)
+ except Exception as error:
+ logging.getLogger(__name__).warning(
+ "Не удалось применить пресет %s для %s: %s",
+ preset_key,
+ setting_key,
+ error,
+ )
+ await db.commit()
+
+ title = PRESET_METADATA.get(preset_key, {}).get("title", preset_key)
+ summary_lines = [
+ f"✅ Пресет {title} применен",
+ "",
+ f"Изменено параметров: {len(applied)}",
+ ]
+ if applied:
+ summary_lines.append("\n".join(f"• {key}" for key in applied))
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text="⬅️ К пресетам", callback_data="botcfg_action:presets"
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text="🏠 Главное меню", callback_data="admin_bot_config"
+ )
+ ],
+ ]
+ )
+
+ await callback.message.edit_text(
+ "\n".join(summary_lines),
+ parse_mode="HTML",
+ reply_markup=keyboard,
+ )
+ await callback.answer("Настройки обновлены", show_alert=False)
+
+
+@admin_required
+@error_handler
+async def export_settings(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ categories = bot_configuration_service.get_categories()
+ keys: List[str] = []
+ for category_key, _label, _count in categories:
+ for definition in bot_configuration_service.get_settings_for_category(category_key):
+ keys.append(definition.key)
+
+ keys = sorted(set(keys))
+ lines = [
+ "# RemnaWave bot configuration export",
+ f"# Generated at {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}",
+ ]
+
+ for setting_key in keys:
+ current_value = bot_configuration_service.get_current_value(setting_key)
+ raw_value = bot_configuration_service.serialize_value(setting_key, current_value)
+ if raw_value is None:
+ raw_value = ""
+ lines.append(f"{setting_key}={raw_value}")
+
+ content = "\n".join(lines)
+ filename = f"bot-settings-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}.env"
+ file = types.BufferedInputFile(content.encode("utf-8"), filename=filename)
+
+ await callback.message.answer_document(
+ document=file,
+ caption="📤 Экспорт текущих настроек",
+ parse_mode="HTML",
+ )
+ await callback.answer("Файл готов", show_alert=False)
+
+
+@admin_required
+@error_handler
+async def start_import_settings(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ await state.set_state(BotConfigStates.waiting_for_import_file)
+ await state.update_data(botcfg_origin="bot_config")
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text="⬅️ Главное меню", callback_data="admin_bot_config"
+ )
+ ]
+ ]
+ )
+
+ await callback.message.edit_text(
+ "📥 Импорт настроек\n\n"
+ "Прикрепите .env файл или отправьте текстом пары KEY=value.\n"
+ "Неизвестные параметры будут проигнорированы.",
+ parse_mode="HTML",
+ reply_markup=keyboard,
+ )
+ await callback.answer("Загрузите файл .env", show_alert=False)
+
+
+@admin_required
+@error_handler
+async def handle_import_message(
+ message: types.Message,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ if message.chat.type != "private":
+ return
+
+ data = await state.get_data()
+ if data.get("botcfg_origin") != "bot_config":
+ return
+
+ content = ""
+ if message.document:
+ buffer = io.BytesIO()
+ await message.document.download(destination=buffer)
+ buffer.seek(0)
+ content = buffer.read().decode("utf-8", errors="ignore")
+ else:
+ content = message.text or ""
+
+ parsed = _parse_env_content(content)
+ if not parsed:
+ await message.answer(
+ "❌ Не удалось найти параметры в файле. Убедитесь, что используется формат KEY=value.",
+ parse_mode="HTML",
+ )
+ await state.clear()
+ return
+
+ applied: List[str] = []
+ skipped: List[str] = []
+ errors: List[str] = []
+
+ for setting_key, raw_value in parsed.items():
+ try:
+ bot_configuration_service.get_definition(setting_key)
+ except KeyError:
+ skipped.append(setting_key)
+ continue
+
+ value_to_apply: Optional[object]
+ try:
+ if raw_value in {"", '""'}:
+ value_to_apply = None
+ else:
+ value_to_apply = bot_configuration_service.deserialize_value(
+ setting_key, raw_value
+ )
+ except Exception as error:
+ errors.append(f"{setting_key}: {error}")
+ continue
+
+ await bot_configuration_service.set_value(db, setting_key, value_to_apply)
+ applied.append(setting_key)
+
+ await db.commit()
+
+ summary_lines = [
+ "📥 Импорт завершен",
+ f"Обновлено параметров: {len(applied)}",
+ ]
+ if applied:
+ summary_lines.append("\n".join(f"• {key}" for key in applied))
+
+ if skipped:
+ summary_lines.append("\nПропущено (неизвестные ключи):")
+ summary_lines.append("\n".join(f"• {key}" for key in skipped))
+
+ if errors:
+ summary_lines.append("\nОшибки разбора:")
+ summary_lines.append("\n".join(f"• {html.escape(err)}" for err in errors))
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text="🏠 Главное меню", callback_data="admin_bot_config"
+ )
+ ]
+ ]
+ )
+
+ await message.answer(
+ "\n".join(summary_lines), parse_mode="HTML", reply_markup=keyboard
+ )
+ await state.clear()
+
+
+@admin_required
+@error_handler
+async def show_settings_history(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ result = await db.execute(
+ select(SystemSetting).order_by(SystemSetting.updated_at.desc()).limit(10)
+ )
+ rows = result.scalars().all()
+
+ lines = ["🕘 История изменений", ""]
+ if rows:
+ for row in rows:
+ timestamp = row.updated_at or row.created_at
+ ts_text = timestamp.strftime("%d.%m %H:%M") if timestamp else "—"
+ try:
+ parsed_value = bot_configuration_service.deserialize_value(row.key, row.value)
+ formatted_value = bot_configuration_service.format_value_human(
+ row.key, parsed_value
+ )
+ except Exception:
+ formatted_value = row.value or "—"
+ lines.append(f"{ts_text} • {row.key} = {formatted_value}")
+ else:
+ lines.append("История изменений пуста.")
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text="⬅️ Главное меню", callback_data="admin_bot_config"
+ )
+ ]
+ ]
+ )
+
+ await callback.message.edit_text(
+ "\n".join(lines), parse_mode="HTML", reply_markup=keyboard
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def show_help(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ text = (
+ "❓ Как работать с панелью\n\n"
+ "• Навигируйте по категориям, чтобы увидеть связанные настройки.\n"
+ "• Значок ✳️ рядом с параметром означает, что значение переопределено.\n"
+ "• Используйте 🔍 поиск для быстрого доступа к нужной настройке.\n"
+ "• Экспортируйте .env перед крупными изменениями, чтобы иметь резервную копию.\n"
+ "• Импорт позволяет восстановить конфигурацию или применить шаблон.\n"
+ "• Все секретные ключи скрываются в интерфейсе автоматически."
+ )
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text="🏠 Главное меню", callback_data="admin_bot_config"
+ )
+ ]
+ ]
+ )
+
+ await callback.message.edit_text(
+ text, parse_mode="HTML", reply_markup=keyboard
+ )
+ await callback.answer()
+
async def _store_setting_context(
state: FSMContext,
@@ -214,15 +1087,56 @@ def _build_groups_keyboard() -> types.InlineKeyboardMarkup:
for group_key, title, items in grouped:
total = sum(count for _, _, count in items)
+ status_icon, _ = _get_group_status(group_key)
+ button_text = f"{status_icon} {title} ({total})"
rows.append(
[
types.InlineKeyboardButton(
- text=f"{title} ({total})",
+ text=button_text,
callback_data=f"botcfg_group:{group_key}:1",
)
]
)
+ rows.append(
+ [
+ types.InlineKeyboardButton(
+ text="🔍 Найти настройку",
+ callback_data="botcfg_action:search",
+ ),
+ types.InlineKeyboardButton(
+ text="🎯 Пресеты",
+ callback_data="botcfg_action:presets",
+ ),
+ ]
+ )
+
+ rows.append(
+ [
+ types.InlineKeyboardButton(
+ text="📤 Экспорт .env",
+ callback_data="botcfg_action:export",
+ ),
+ types.InlineKeyboardButton(
+ text="📥 Импорт .env",
+ callback_data="botcfg_action:import",
+ ),
+ ]
+ )
+
+ rows.append(
+ [
+ types.InlineKeyboardButton(
+ text="🕘 История",
+ callback_data="botcfg_action:history",
+ ),
+ types.InlineKeyboardButton(
+ text="❓ Помощь",
+ callback_data="botcfg_action:help",
+ ),
+ ]
+ )
+
rows.append(
[
types.InlineKeyboardButton(
@@ -249,10 +1163,15 @@ def _build_categories_keyboard(
sliced = categories[start:end]
rows: list[list[types.InlineKeyboardButton]] = []
+ status_icon, _status_text = (
+ _get_group_status(group_key)
+ if group_key != CATEGORY_FALLBACK_KEY
+ else ("⚪", "Прочие настройки")
+ )
rows.append(
[
types.InlineKeyboardButton(
- text=f"— {group_title} —",
+ text=f"{status_icon} {group_title}",
callback_data="botcfg_group:noop",
)
]
@@ -260,7 +1179,12 @@ def _build_categories_keyboard(
buttons: List[types.InlineKeyboardButton] = []
for category_key, label, count in sliced:
- button_text = f"{label} ({count})"
+ overrides = 0
+ for definition in bot_configuration_service.get_settings_for_category(category_key):
+ if bot_configuration_service.has_override(definition.key):
+ overrides += 1
+ badge = "✳️" if overrides else "•"
+ button_text = f"{badge} {label} ({count})"
buttons.append(
types.InlineKeyboardButton(
text=button_text,
@@ -370,8 +1294,13 @@ def _build_settings_keyboard(
rows.extend(test_payment_buttons)
for definition in sliced:
+ current_value = bot_configuration_service.get_current_value(definition.key)
value_preview = bot_configuration_service.format_value_for_list(definition.key)
- button_text = f"{definition.display_name} · {value_preview}"
+ icon = _get_setting_icon(definition, current_value)
+ override_badge = "✳️" if bot_configuration_service.has_override(definition.key) else "•"
+ button_text = f"{override_badge} {icon} {definition.display_name}"
+ if value_preview != "—":
+ button_text += f" · {value_preview}"
if len(button_text) > 64:
button_text = button_text[:63] + "…"
callback_token = bot_configuration_service.get_callback_token(definition.key)
@@ -499,26 +1428,32 @@ def _build_setting_keyboard(
def _render_setting_text(key: str) -> str:
summary = bot_configuration_service.get_setting_summary(key)
+ guidance = bot_configuration_service.get_setting_guidance(key)
lines = [
- "🧩 Настройка",
- f"Название: {summary['name']}",
- f"Ключ: {summary['key']}",
- f"Категория: {summary['category_label']}",
- f"Тип: {summary['type']}",
- f"Текущее значение: {summary['current']}",
- f"Значение по умолчанию: {summary['original']}",
- f"Переопределено в БД: {'✅ Да' if summary['has_override'] else '❌ Нет'}",
+ f"🧩 {summary['name']}",
+ f"🔑 Ключ: {summary['key']}",
+ f"📁 Категория: {summary['category_label']}",
+ f"📝 Тип: {guidance['type']}",
+ f"📌 Текущее: {summary['current']}",
+ f"📦 По умолчанию: {summary['original']}",
+ f"✳️ Переопределено: {'Да' if summary['has_override'] else 'Нет'}",
+ "",
+ f"📘 Описание: {guidance['description']}",
+ f"📐 Формат: {guidance['format']}",
+ f"💡 Пример: {guidance['example']}",
+ f"⚠️ Важно: {guidance['warning']}",
+ f"🔗 Связанные настройки: {guidance['dependencies']}",
]
choices = bot_configuration_service.get_choice_options(key)
if choices:
current_raw = bot_configuration_service.get_current_value(key)
lines.append("")
- lines.append("Доступные значения:")
+ lines.append("📋 Доступные значения:")
for option in choices:
marker = "✅" if current_raw == option.value else "•"
- value_display = bot_configuration_service.format_value(option.value)
+ value_display = bot_configuration_service.format_value_human(key, option.value)
description = option.description or ""
if description:
lines.append(
@@ -536,11 +1471,15 @@ async def show_bot_config_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
+ state: FSMContext,
):
+ await state.clear()
keyboard = _build_groups_keyboard()
+ overview = _render_dashboard_overview()
await callback.message.edit_text(
- "🧩 Конфигурация бота\n\nВыберите раздел настроек:",
+ overview,
reply_markup=keyboard,
+ parse_mode="HTML",
)
await callback.answer()
@@ -562,9 +1501,19 @@ async def show_bot_config_group(
group_title, items = group_lookup[group_key]
keyboard = _build_categories_keyboard(group_key, group_title, items, page)
+ status_icon, status_text = _get_group_status(group_key)
+ description = _get_group_description(group_key)
+ lines = [f"{status_icon} {group_title}"]
+ if description:
+ lines.append(description)
+ if status_text:
+ lines.append(f"Статус: {status_text}")
+ lines.append("")
+ lines.append("📂 Выберите категорию настроек:")
await callback.message.edit_text(
- f"🧩 {group_title}\n\nВыберите категорию настроек:",
+ "\n".join(lines),
reply_markup=keyboard,
+ parse_mode="HTML",
)
await callback.answer()
@@ -586,6 +1535,9 @@ async def show_bot_config_category(
return
category_label = definitions[0].category_label
+ category_description = bot_configuration_service.get_category_description(category_key)
+ group_meta = _get_group_meta(group_key)
+ group_title = str(group_meta.get("title", group_key))
keyboard = _build_settings_keyboard(
category_key,
group_key,
@@ -593,9 +1545,18 @@ async def show_bot_config_category(
db_user.language,
settings_page,
)
+ text_lines = [
+ f"🗂 {category_label}",
+ f"Навигация: 🏠 Главное → {group_title} → {category_label}",
+ ]
+ if category_description:
+ text_lines.append(category_description)
+ text_lines.append("")
+ text_lines.append("📋 Выберите настройку для просмотра или редактирования:")
await callback.message.edit_text(
- f"🧩 {category_label}\n\nВыберите настройку для просмотра:",
+ "\n".join(text_lines),
reply_markup=keyboard,
+ parse_mode="HTML",
)
await callback.answer()
@@ -1371,6 +2332,38 @@ def register_handlers(dp: Dispatcher) -> None:
show_bot_config_menu,
F.data == "admin_bot_config",
)
+ dp.callback_query.register(
+ start_settings_search,
+ F.data == "botcfg_action:search",
+ )
+ dp.callback_query.register(
+ show_presets,
+ F.data == "botcfg_action:presets",
+ )
+ dp.callback_query.register(
+ apply_preset,
+ F.data.startswith("botcfg_preset_apply:"),
+ )
+ dp.callback_query.register(
+ preview_preset,
+ F.data.startswith("botcfg_preset:") & (~F.data.startswith("botcfg_preset_apply:")),
+ )
+ dp.callback_query.register(
+ export_settings,
+ F.data == "botcfg_action:export",
+ )
+ dp.callback_query.register(
+ start_import_settings,
+ F.data == "botcfg_action:import",
+ )
+ dp.callback_query.register(
+ show_settings_history,
+ F.data == "botcfg_action:history",
+ )
+ dp.callback_query.register(
+ show_help,
+ F.data == "botcfg_action:help",
+ )
dp.callback_query.register(
show_bot_config_group,
F.data.startswith("botcfg_group:") & (~F.data.endswith(":noop")),
@@ -1417,4 +2410,12 @@ def register_handlers(dp: Dispatcher) -> None:
handle_edit_setting,
BotConfigStates.waiting_for_value,
)
+ dp.message.register(
+ handle_search_query,
+ BotConfigStates.waiting_for_search_query,
+ )
+ dp.message.register(
+ handle_import_message,
+ BotConfigStates.waiting_for_import_file,
+ )
diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py
index 1ce9432a..25676a90 100644
--- a/app/services/system_settings_service.py
+++ b/app/services/system_settings_service.py
@@ -60,92 +60,158 @@ class BotConfigurationService:
EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"}
CATEGORY_TITLES: Dict[str, str] = {
- "SUPPORT": "💬 Ссылка на поддержку",
- "LOCALIZATION": "🌍 Языковые настройки",
- "MAINTENANCE": "🛠️ Режим обслуживания",
+ "CORE": "🤖 Основные настройки",
+ "SUPPORT": "💬 Поддержка и тикеты",
+ "LOCALIZATION": "🌍 Языки интерфейса",
"CHANNEL": "📣 Обязательная подписка",
- "ADMIN_NOTIFICATIONS": "🔔 Уведомления админам",
- "ADMIN_REPORTS": "📊 Автоматические отчеты",
- "TRIAL": "🎁 Триальная подписка",
- "PAID_SUBSCRIPTION": "💰 Платные подписки",
- "PERIODS": "📅 Периоды подписки",
- "SUBSCRIPTION_PRICES": "💵 Цены за периоды",
- "TRAFFIC": "🚦 Настройки трафика",
- "TRAFFIC_PACKAGES": "📦 Пакеты трафика",
- "DISCOUNTS": "🎯 Промо и скидки",
- "PAYMENT": "⚙️ Общие настройки платежей",
+ "PAYMENT": "💳 Общие платежные настройки",
"TELEGRAM": "⭐ Telegram Stars",
- "CRYPTOBOT": "💎 CryptoBot",
- "YOOKASSA": "💸 YooKassa",
+ "CRYPTOBOT": "🪙 CryptoBot",
+ "YOOKASSA": "🟣 YooKassa",
"TRIBUTE": "🎁 Tribute",
"MULENPAY": "💰 MulenPay",
- "PAL24": "🏦 Pal24/PayPalych",
- "REMNAWAVE": "🔗 RemnaWave API",
- "REFERRAL": "🤝 Реферальная система",
+ "PAL24": "🏦 PAL24 / PayPalych",
+ "SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты",
+ "PERIODS": "📆 Периоды подписок",
+ "SUBSCRIPTION_PRICES": "💵 Стоимость тарифов",
+ "TRAFFIC": "📊 Трафик",
+ "TRAFFIC_PACKAGES": "📦 Пакеты трафика",
+ "TRIAL": "🎁 Пробный период",
+ "REFERRAL": "👥 Реферальная программа",
"AUTOPAY": "🔄 Автопродление",
- "INTERFACE_BRANDING": "🖼️ Визуальные настройки",
- "INTERFACE_SUBSCRIPTION": "🔗 Скрыть ссылку подписки",
- "CONNECT_BUTTON": "🚀 Кнопка «Подключиться»",
- "HAPP": "🅷 Happ настройки",
- "SKIP": "⚡ Быстрый старт",
- "ADDITIONAL": "📱 Приложения и DeepLinks",
- "MINIAPP": "📱 Mini App",
- "DATABASE": "🗄️ Режим БД",
- "POSTGRES": "🐘 PostgreSQL",
- "SQLITE": "💾 SQLite",
- "REDIS": "🧠 Redis",
- "MONITORING": "📈 Общий мониторинг",
"NOTIFICATIONS": "🔔 Уведомления пользователям",
- "SERVER": "🖥️ Статус серверов",
- "BACKUP": "💾 Система бэкапов",
- "VERSION": "🔄 Обновления",
- "LOG": "📝 Логирование",
+ "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",
- "WEB_API": "🌐 Web API",
- "DEBUG": "🔧 Режим разработки",
+ "LOG": "📝 Логирование",
+ "DEBUG": "🧪 Режим разработки",
+ }
+
+ CATEGORY_DESCRIPTIONS: Dict[str, str] = {
+ "CORE": "Базовые параметры работы бота и обязательные ссылки.",
+ "SUPPORT": "Контакты поддержки, SLA и режимы обработки обращений.",
+ "LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.",
+ "CHANNEL": "Настройки обязательной подписки на канал или группу.",
+ "PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
+ "YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
+ "CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
+ "MULENPAY": "Платежи MulenPay и параметры магазина.",
+ "PAL24": "PAL24 / PayPalych подключения и лимиты.",
+ "TRIBUTE": "Tribute и донат-сервисы.",
+ "TELEGRAM": "Telegram Stars и их стоимость.",
+ "SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.",
+ "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": "Отладочные функции и безопасный режим.",
}
CATEGORY_KEY_OVERRIDES: Dict[str, str] = {
"DATABASE_URL": "DATABASE",
"DATABASE_MODE": "DATABASE",
- "LOCALES_PATH": "DATABASE",
- "DEFAULT_DEVICE_LIMIT": "PAID_SUBSCRIPTION",
- "DEFAULT_TRAFFIC_LIMIT_GB": "PAID_SUBSCRIPTION",
- "MAX_DEVICES_LIMIT": "PAID_SUBSCRIPTION",
- "PRICE_PER_DEVICE": "PAID_SUBSCRIPTION",
+ "LOCALES_PATH": "LOCALIZATION",
+ "CHANNEL_SUB_ID": "CHANNEL",
+ "CHANNEL_LINK": "CHANNEL",
+ "CHANNEL_IS_REQUIRED_SUB": "CHANNEL",
+ "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",
+ "BASE_SUBSCRIPTION_PRICE": "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",
- "BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTION_PRICES",
"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",
- "PRICE_TRAFFIC_5GB": "TRAFFIC_PACKAGES",
- "PRICE_TRAFFIC_10GB": "TRAFFIC_PACKAGES",
- "PRICE_TRAFFIC_25GB": "TRAFFIC_PACKAGES",
- "PRICE_TRAFFIC_50GB": "TRAFFIC_PACKAGES",
- "PRICE_TRAFFIC_100GB": "TRAFFIC_PACKAGES",
- "PRICE_TRAFFIC_250GB": "TRAFFIC_PACKAGES",
- "PRICE_TRAFFIC_500GB": "TRAFFIC_PACKAGES",
- "PRICE_TRAFFIC_1000GB": "TRAFFIC_PACKAGES",
- "PRICE_TRAFFIC_UNLIMITED": "TRAFFIC_PACKAGES",
"TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES",
- "BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "DISCOUNTS",
- "BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "DISCOUNTS",
+ "BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE",
+ "BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE",
"REFERRED_USER_REWARD": "REFERRAL",
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
- "TRIAL_WARNING_HOURS": "NOTIFICATIONS",
+ "TRIAL_WARNING_HOURS": "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",
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
"MONITORING_LOGS_RETENTION_DAYS": "MONITORING",
+ "MONITORING_INTERVAL": "MONITORING",
"ENABLE_LOGO_MODE": "INTERFACE_BRANDING",
"LOGO_FILE": "INTERFACE_BRANDING",
"HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION",
@@ -154,15 +220,17 @@ class BotConfigurationService:
"APP_CONFIG_PATH": "ADDITIONAL",
"ENABLE_DEEP_LINKS": "ADDITIONAL",
"APP_CONFIG_CACHE_TTL": "ADDITIONAL",
- "DEFAULT_LANGUAGE": "LOCALIZATION",
- "AVAILABLE_LANGUAGES": "LOCALIZATION",
- "PAYMENT_SERVICE_NAME": "PAYMENT",
- "PAYMENT_BALANCE_DESCRIPTION": "PAYMENT",
- "PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
- "PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
- "PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
- "INACTIVE_USER_DELETE_MONTHS": "MONITORING",
- "LANGUAGE_SELECTION_ENABLED": "LOCALIZATION",
+ "INACTIVE_USER_DELETE_MONTHS": "MAINTENANCE",
+ "MAINTENANCE_MESSAGE": "MAINTENANCE",
+ "MAINTENANCE_CHECK_INTERVAL": "MAINTENANCE",
+ "MAINTENANCE_AUTO_ENABLE": "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",
}
CATEGORY_PREFIX_OVERRIDES: Dict[str, str] = {
@@ -193,7 +261,7 @@ class BotConfigurationService:
"MINIAPP_": "MINIAPP",
"MONITORING_": "MONITORING",
"NOTIFICATION_": "NOTIFICATIONS",
- "SERVER_STATUS": "SERVER",
+ "SERVER_STATUS": "SERVER_STATUS",
"MAINTENANCE_": "MAINTENANCE",
"VERSION_CHECK": "VERSION",
"BACKUP_": "BACKUP",
@@ -290,6 +358,153 @@ class BotConfigurationService:
],
}
+ SETTING_HINTS: Dict[str, Dict[str, str]] = {
+ "YOOKASSA_ENABLED": {
+ "description": "Включает оплату через YooKassa. Требует корректных идентификаторов магазина и секретного ключа.",
+ "format": "Булево значение: выберите \"Включить\" или \"Выключить\".",
+ "example": "Включено при полностью настроенной интеграции.",
+ "warning": "При включении без Shop ID и Secret Key пользователи увидят ошибки при оплате.",
+ "dependencies": "YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY, YOOKASSA_RETURN_URL",
+ },
+ "CRYPTOBOT_ENABLED": {
+ "description": "Разрешает принимать криптоплатежи через CryptoBot.",
+ "format": "Булево значение.",
+ "example": "Включите после указания токена API и секрета вебхука.",
+ "warning": "Пустой токен или неверный вебхук приведут к отказам платежей.",
+ "dependencies": "CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET",
+ },
+ "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",
+ },
+ "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",
+ },
+ }
+
+ @classmethod
+ def get_category_description(cls, category_key: str) -> str:
+ return cls.CATEGORY_DESCRIPTIONS.get(category_key, "")
+
+ @classmethod
+ def is_toggle(cls, key: str) -> bool:
+ definition = cls.get_definition(key)
+ return definition.python_type is bool
+
+ @classmethod
+ def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]:
+ 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) -> Optional[List[str]]:
+ 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 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 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, Optional[str]] = {}
@@ -443,7 +658,7 @@ class BotConfigurationService:
@classmethod
def format_value_for_list(cls, key: str) -> str:
value = cls.get_current_value(key)
- formatted = cls.format_value(value)
+ formatted = cls.format_value_human(key, value)
if formatted == "—":
return formatted
return _truncate(formatted)
@@ -693,8 +908,8 @@ class BotConfigurationService:
return {
"key": key,
"name": definition.display_name,
- "current": cls.format_value(current),
- "original": cls.format_value(original),
+ "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,
diff --git a/app/states.py b/app/states.py
index 43655b35..61015d48 100644
--- a/app/states.py
+++ b/app/states.py
@@ -132,6 +132,8 @@ class SupportSettingsStates(StatesGroup):
class BotConfigStates(StatesGroup):
waiting_for_value = State()
+ waiting_for_search_query = State()
+ waiting_for_import_file = State()
class AutoPayStates(StatesGroup):
setting_autopay_days = State()