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