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