mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-28 07:11:37 +00:00
2797 lines
98 KiB
Python
2797 lines
98 KiB
Python
import html
|
||
import io
|
||
import logging
|
||
import math
|
||
import time
|
||
from collections.abc import Iterable
|
||
from datetime import datetime
|
||
|
||
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.config import settings
|
||
from app.database.crud.server_squad import (
|
||
get_all_server_squads,
|
||
get_server_squad_by_id,
|
||
get_server_squad_by_uuid,
|
||
)
|
||
from app.database.models import SystemSetting, User
|
||
from app.external.telegram_stars import TelegramStarsService
|
||
from app.localization.texts import get_texts
|
||
from app.services.payment_service import PaymentService
|
||
from app.services.remnawave_service import RemnaWaveService
|
||
from app.services.system_settings_service import (
|
||
ReadOnlySettingError,
|
||
bot_configuration_service,
|
||
)
|
||
from app.services.tribute_service import TributeService
|
||
from app.states import BotConfigStates
|
||
from app.utils.currency_converter import currency_converter
|
||
from app.utils.decorators import admin_required, error_handler
|
||
|
||
|
||
CATEGORY_PAGE_SIZE = 10
|
||
SETTINGS_PAGE_SIZE = 8
|
||
SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE = 6
|
||
|
||
CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||
'core': {
|
||
'title': '🤖 Основные',
|
||
'description': 'Базовые настройки бота, обязательные каналы и ключевые сервисы.',
|
||
'icon': '🤖',
|
||
'categories': (
|
||
'CORE',
|
||
'CHANNEL',
|
||
'TIMEZONE',
|
||
'DATABASE',
|
||
'POSTGRES',
|
||
'SQLITE',
|
||
'REDIS',
|
||
'REMNAWAVE',
|
||
),
|
||
},
|
||
'support': {
|
||
'title': '💬 Поддержка',
|
||
'description': 'Контакты, режимы тикетов, SLA и уведомления модераторов.',
|
||
'icon': '💬',
|
||
'categories': ('SUPPORT',),
|
||
},
|
||
'payments': {
|
||
'title': '💳 Платежные системы',
|
||
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI и Telegram Stars.',
|
||
'icon': '💳',
|
||
'categories': (
|
||
'PAYMENT',
|
||
'PAYMENT_VERIFICATION',
|
||
'YOOKASSA',
|
||
'CRYPTOBOT',
|
||
'HELEKET',
|
||
'CLOUDPAYMENTS',
|
||
'FREEKASSA',
|
||
'KASSA_AI',
|
||
'MULENPAY',
|
||
'PAL24',
|
||
'WATA',
|
||
'PLATEGA',
|
||
'TRIBUTE',
|
||
'TELEGRAM',
|
||
),
|
||
},
|
||
'subscriptions': {
|
||
'title': '📅 Подписки и цены',
|
||
'description': 'Тарифы, простая покупка, периоды, лимиты трафика и автопродление.',
|
||
'icon': '📅',
|
||
'categories': (
|
||
'SUBSCRIPTIONS_CORE',
|
||
'SIMPLE_SUBSCRIPTION',
|
||
'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',
|
||
'INTERFACE_BRANDING',
|
||
'INTERFACE_SUBSCRIPTION',
|
||
'CONNECT_BUTTON',
|
||
'MINIAPP',
|
||
'HAPP',
|
||
'SKIP',
|
||
'LOCALIZATION',
|
||
'ADDITIONAL',
|
||
),
|
||
},
|
||
'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',
|
||
'MODERATION',
|
||
'DEBUG',
|
||
'EXTERNAL_ADMIN',
|
||
),
|
||
},
|
||
}
|
||
|
||
CATEGORY_GROUP_ORDER: tuple[str, ...] = (
|
||
'core',
|
||
'support',
|
||
'payments',
|
||
'subscriptions',
|
||
'trial',
|
||
'referral',
|
||
'notifications',
|
||
'interface',
|
||
'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(),
|
||
'Platega': settings.is_platega_enabled(),
|
||
'CloudPayments': settings.is_cloudpayments_enabled(),
|
||
'Freekassa': settings.is_freekassa_enabled(),
|
||
'Kassa AI': settings.is_kassa_ai_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
|
||
)
|
||
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] = [
|
||
'⚙️ <b>ПАНЕЛЬ УПРАВЛЕНИЯ БОТОМ</b>',
|
||
'',
|
||
f'Всего параметров: <b>{total_settings}</b> • Переопределено: <b>{total_overrides}</b>',
|
||
'',
|
||
'<b>Группы настроек</b>',
|
||
'',
|
||
]
|
||
|
||
for group_key, title, items in grouped:
|
||
status_icon, status_text = _get_group_status(group_key)
|
||
total = sum(count for _, _, count in items)
|
||
lines.append(f'{status_icon} <b>{title}</b> — {status_text}')
|
||
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, str | None]:
|
||
parsed: dict[str, str | None] = {}
|
||
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(
|
||
'🔍 <b>Поиск по настройкам</b>\n\n'
|
||
'Отправьте часть ключа или названия настройки. \n'
|
||
'Например: <code>yookassa</code> или <code>уведомления</code>.',
|
||
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 = [
|
||
'🔍 <b>Результаты поиска</b>',
|
||
f'Запрос: <code>{html.escape(query)}</code>',
|
||
'',
|
||
]
|
||
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 = (
|
||
'🔍 <b>Результаты поиска</b>\n\n'
|
||
f'Запрос: <code>{html.escape(query)}</code>\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 = [
|
||
'🎯 <b>Готовые пресеты</b>',
|
||
'',
|
||
'Выберите набор параметров, чтобы быстро применить его к боту.',
|
||
'',
|
||
]
|
||
for key, meta in PRESET_METADATA.items():
|
||
lines.append(f'• <b>{meta["title"]}</b> — {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'🎯 <b>{title}</b>']
|
||
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}. <code>{setting_key}</code>\n Текущее: {current_pretty}\n Новое: {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 ReadOnlySettingError:
|
||
logging.getLogger(__name__).info(
|
||
'Пропускаем настройку %s из пресета %s: только для чтения',
|
||
setting_key,
|
||
preset_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'✅ Пресет <b>{title}</b> применен',
|
||
'',
|
||
f'Изменено параметров: <b>{len(applied)}</b>',
|
||
]
|
||
if applied:
|
||
summary_lines.append('\n'.join(f'• <code>{key}</code>' 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(
|
||
'📥 <b>Импорт настроек</b>\n\n'
|
||
'Прикрепите .env файл или отправьте текстом пары <code>KEY=value</code>.\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: object | None
|
||
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
|
||
|
||
if bot_configuration_service.is_read_only(setting_key):
|
||
skipped.append(setting_key)
|
||
continue
|
||
try:
|
||
await bot_configuration_service.set_value(db, setting_key, value_to_apply)
|
||
applied.append(setting_key)
|
||
except ReadOnlySettingError:
|
||
skipped.append(setting_key)
|
||
|
||
await db.commit()
|
||
|
||
summary_lines = [
|
||
'📥 <b>Импорт завершен</b>',
|
||
f'Обновлено параметров: <b>{len(applied)}</b>',
|
||
]
|
||
if applied:
|
||
summary_lines.append('\n'.join(f'• <code>{key}</code>' for key in applied))
|
||
|
||
if skipped:
|
||
summary_lines.append('\nПропущено (неизвестные ключи):')
|
||
summary_lines.append('\n'.join(f'• <code>{key}</code>' 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 = ['🕘 <b>История изменений</b>', '']
|
||
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} • <code>{row.key}</code> = {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 = (
|
||
'❓ <b>Как работать с панелью</b>\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,
|
||
*,
|
||
key: str,
|
||
group_key: str,
|
||
category_page: int,
|
||
settings_page: int,
|
||
) -> None:
|
||
await state.update_data(
|
||
setting_key=key,
|
||
setting_group_key=group_key,
|
||
setting_category_page=category_page,
|
||
setting_settings_page=settings_page,
|
||
botcfg_origin='bot_config',
|
||
botcfg_timestamp=time.time(),
|
||
)
|
||
|
||
|
||
class BotConfigInputFilter(BaseFilter):
|
||
def __init__(self, timeout: float = 300.0) -> None:
|
||
self.timeout = timeout
|
||
|
||
async def __call__(
|
||
self,
|
||
message: types.Message,
|
||
state: FSMContext,
|
||
) -> bool:
|
||
if not message.text or message.text.startswith('/'):
|
||
return False
|
||
|
||
if message.chat.type != 'private':
|
||
return False
|
||
|
||
data = await state.get_data()
|
||
|
||
if data.get('botcfg_origin') != 'bot_config':
|
||
return False
|
||
|
||
if not data.get('setting_key'):
|
||
return False
|
||
|
||
timestamp = data.get('botcfg_timestamp')
|
||
if timestamp is None:
|
||
return True
|
||
|
||
try:
|
||
return (time.time() - float(timestamp)) <= self.timeout
|
||
except (TypeError, ValueError):
|
||
return False
|
||
|
||
|
||
def _chunk(buttons: Iterable[types.InlineKeyboardButton], size: int) -> Iterable[list[types.InlineKeyboardButton]]:
|
||
buttons_list = list(buttons)
|
||
for index in range(0, len(buttons_list), size):
|
||
yield buttons_list[index : index + size]
|
||
|
||
|
||
def _parse_category_payload(payload: str) -> tuple[str, str, int, int]:
|
||
parts = payload.split(':')
|
||
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 group_key, page
|
||
|
||
|
||
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]]]] = []
|
||
|
||
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))
|
||
|
||
remaining = [(key, label, count) for key, (label, count) in categories_map.items() if key not in used]
|
||
|
||
if remaining:
|
||
remaining.sort(key=lambda item: item[1])
|
||
grouped.append((CATEGORY_FALLBACK_KEY, CATEGORY_FALLBACK_TITLE, remaining))
|
||
|
||
return grouped
|
||
|
||
|
||
def _build_groups_keyboard() -> types.InlineKeyboardMarkup:
|
||
grouped = _get_grouped_categories()
|
||
rows: list[list[types.InlineKeyboardButton]] = []
|
||
|
||
for group_key, title, items in grouped:
|
||
sum(count for _, _, count in items)
|
||
status_icon, status_text = _get_group_status(group_key)
|
||
button_text = f'{status_icon} {title} — {status_text}'
|
||
rows.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
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(
|
||
text='⬅️ Назад в админку',
|
||
callback_data='admin_submenu_settings',
|
||
)
|
||
]
|
||
)
|
||
|
||
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]] = []
|
||
|
||
buttons: list[types.InlineKeyboardButton] = []
|
||
for category_key, label, count in sliced:
|
||
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,
|
||
callback_data=f'botcfg_cat:{group_key}:{category_key}:{page}:1',
|
||
)
|
||
)
|
||
|
||
for chunk in _chunk(buttons, 2):
|
||
rows.append(list(chunk))
|
||
|
||
if total_pages > 1:
|
||
nav_row: list[types.InlineKeyboardButton] = []
|
||
if page > 1:
|
||
nav_row.append(
|
||
types.InlineKeyboardButton(
|
||
text='⬅️',
|
||
callback_data=f'botcfg_group:{group_key}:{page - 1}',
|
||
)
|
||
)
|
||
nav_row.append(
|
||
types.InlineKeyboardButton(
|
||
text=f'[{page}/{total_pages}]',
|
||
callback_data='botcfg_group:noop',
|
||
)
|
||
)
|
||
if page < total_pages:
|
||
nav_row.append(
|
||
types.InlineKeyboardButton(
|
||
text='➡️',
|
||
callback_data=f'botcfg_group:{group_key}:{page + 1}',
|
||
)
|
||
)
|
||
rows.append(nav_row)
|
||
|
||
rows.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='⬅️ К разделам',
|
||
callback_data='admin_bot_config',
|
||
)
|
||
]
|
||
)
|
||
|
||
return types.InlineKeyboardMarkup(inline_keyboard=rows)
|
||
|
||
|
||
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]] = []
|
||
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}'),
|
||
)
|
||
]
|
||
)
|
||
|
||
test_payment_buttons: list[list[types.InlineKeyboardButton]] = []
|
||
|
||
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',
|
||
'💳 Банковская карта ({mulenpay_name})',
|
||
).format(mulenpay_name=settings.get_mulenpay_display_name())
|
||
test_payment_buttons.append([_test_button(f'{label} · тест', 'mulenpay')])
|
||
elif category_key == 'WATA':
|
||
label = texts.t('PAYMENT_CARD_WATA', '💳 Банковская карта (WATA)')
|
||
test_payment_buttons.append([_test_button(f'{label} · тест', 'wata')])
|
||
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')])
|
||
elif category_key == 'FREEKASSA':
|
||
label = texts.t('PAYMENT_FREEKASSA', '💳 Freekassa')
|
||
test_payment_buttons.append([_test_button(f'{label} · тест', 'freekassa')])
|
||
elif category_key == 'KASSA_AI':
|
||
label = texts.t('PAYMENT_KASSA_AI', f'💳 {settings.get_kassa_ai_display_name()}')
|
||
test_payment_buttons.append([_test_button(f'{label} · тест', 'kassa_ai')])
|
||
|
||
if test_payment_buttons:
|
||
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)
|
||
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)
|
||
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)
|
||
|
||
|
||
def _build_setting_keyboard(
|
||
key: str,
|
||
group_key: str,
|
||
category_page: int,
|
||
settings_page: int,
|
||
) -> types.InlineKeyboardMarkup:
|
||
definition = bot_configuration_service.get_definition(key)
|
||
rows: list[list[types.InlineKeyboardButton]] = []
|
||
callback_token = bot_configuration_service.get_callback_token(key)
|
||
is_read_only = bot_configuration_service.is_read_only(key)
|
||
|
||
choice_options = bot_configuration_service.get_choice_options(key)
|
||
if choice_options and not is_read_only:
|
||
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)
|
||
if choice_token is None:
|
||
continue
|
||
button_text = option.label
|
||
if current_value == option.value and not button_text.startswith('✅'):
|
||
button_text = f'✅ {button_text}'
|
||
choice_buttons.append(
|
||
types.InlineKeyboardButton(
|
||
text=button_text,
|
||
callback_data=(
|
||
f'botcfg_choice:{group_key}:{category_page}:{settings_page}:{callback_token}:{choice_token}'
|
||
),
|
||
)
|
||
)
|
||
|
||
for chunk in _chunk(choice_buttons, 2):
|
||
rows.append(list(chunk))
|
||
|
||
if key == 'SIMPLE_SUBSCRIPTION_SQUAD_UUID' and not is_read_only:
|
||
rows.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='🌍 Выбрать сквад',
|
||
callback_data=(
|
||
f'botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{callback_token}:1'
|
||
),
|
||
)
|
||
]
|
||
)
|
||
|
||
if definition.python_type is bool and not is_read_only:
|
||
rows.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='🔁 Переключить',
|
||
callback_data=(f'botcfg_toggle:{group_key}:{category_page}:{settings_page}:{callback_token}'),
|
||
)
|
||
]
|
||
)
|
||
|
||
if not is_read_only:
|
||
rows.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='✏️ Изменить',
|
||
callback_data=(f'botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}'),
|
||
)
|
||
]
|
||
)
|
||
|
||
if bot_configuration_service.has_override(key) and not is_read_only:
|
||
rows.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='♻️ Сбросить',
|
||
callback_data=(f'botcfg_reset:{group_key}:{category_page}:{settings_page}:{callback_token}'),
|
||
)
|
||
]
|
||
)
|
||
|
||
if is_read_only:
|
||
rows.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='🔒 Только для чтения',
|
||
callback_data='botcfg_group:noop',
|
||
)
|
||
]
|
||
)
|
||
|
||
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) -> str:
|
||
summary = bot_configuration_service.get_setting_summary(key)
|
||
guidance = bot_configuration_service.get_setting_guidance(key)
|
||
|
||
definition = bot_configuration_service.get_definition(key)
|
||
|
||
description = guidance.get('description') or '—'
|
||
format_hint = guidance.get('format') or '—'
|
||
example = guidance.get('example') or '—'
|
||
warning = guidance.get('warning') or '—'
|
||
dependencies = guidance.get('dependencies') or '—'
|
||
type_label = guidance.get('type') or summary.get('type') or definition.type_label
|
||
|
||
lines = [
|
||
f'🧩 <b>{summary["name"]}</b>',
|
||
f'🔑 Ключ: <code>{summary["key"]}</code>',
|
||
f'📁 Категория: {summary["category_label"]}',
|
||
f'📝 Тип: {type_label}',
|
||
f'📌 Текущее: {summary["current"]}',
|
||
]
|
||
|
||
original_value = summary.get('original')
|
||
if original_value not in {None, ''}:
|
||
lines.append(f'📦 По умолчанию: {original_value}')
|
||
|
||
lines.append(f'✳️ Переопределено: {"Да" if summary["has_override"] else "Нет"}')
|
||
|
||
if summary.get('is_read_only'):
|
||
lines.append('🔒 Режим: Только для чтения (управляется автоматически)')
|
||
|
||
lines.append('')
|
||
if description:
|
||
lines.append(f'📘 Описание: {description}')
|
||
if format_hint:
|
||
lines.append(f'📐 Формат: {format_hint}')
|
||
if example:
|
||
lines.append(f'💡 Пример: {example}')
|
||
if warning:
|
||
lines.append(f'⚠️ Важно: {warning}')
|
||
if dependencies:
|
||
lines.append(f'🔗 Связанные: {dependencies}')
|
||
|
||
choices = bot_configuration_service.get_choice_options(key)
|
||
if choices:
|
||
current_raw = bot_configuration_service.get_current_value(key)
|
||
lines.append('')
|
||
lines.append('📋 Доступные значения:')
|
||
for option in choices:
|
||
marker = '✅' if current_raw == option.value else '•'
|
||
value_display = bot_configuration_service.format_value_human(key, option.value)
|
||
description = option.description or ''
|
||
base_line = f'{marker} {option.label} — <code>{value_display}</code>'
|
||
if description:
|
||
base_line += f'\n└ {description}'
|
||
lines.append(base_line)
|
||
|
||
return '\n'.join(lines)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
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(
|
||
overview,
|
||
reply_markup=keyboard,
|
||
parse_mode='HTML',
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_bot_config_group(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
):
|
||
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
|
||
|
||
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)
|
||
icon = _get_group_icon(group_key)
|
||
raw_title = str(group_title).strip()
|
||
clean_title = raw_title
|
||
if icon and raw_title.startswith(icon):
|
||
clean_title = raw_title[len(icon) :].strip()
|
||
elif ' ' in raw_title:
|
||
possible_icon, remainder = raw_title.split(' ', 1)
|
||
if possible_icon:
|
||
icon = possible_icon
|
||
clean_title = remainder.strip()
|
||
lines = [f'{icon} <b>{clean_title}</b>']
|
||
if status_text:
|
||
lines.append(f'Статус: {status_icon} {status_text}')
|
||
lines.append(f'🏠 → {clean_title}')
|
||
if description:
|
||
lines.append('')
|
||
lines.append(description)
|
||
lines.append('')
|
||
lines.append('📂 Категории группы:')
|
||
await callback.message.edit_text(
|
||
'\n'.join(lines),
|
||
reply_markup=keyboard,
|
||
parse_mode='HTML',
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_bot_config_category(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
):
|
||
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
|
||
|
||
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))
|
||
group_icon = _get_group_icon(group_key)
|
||
raw_group_title = group_title.strip()
|
||
if group_icon and raw_group_title.startswith(group_icon):
|
||
group_plain_title = raw_group_title[len(group_icon) :].strip()
|
||
elif ' ' in raw_group_title:
|
||
possible_icon, remainder = raw_group_title.split(' ', 1)
|
||
group_plain_title = remainder.strip()
|
||
if possible_icon:
|
||
group_icon = possible_icon
|
||
else:
|
||
group_plain_title = raw_group_title
|
||
keyboard = _build_settings_keyboard(
|
||
category_key,
|
||
group_key,
|
||
category_page,
|
||
db_user.language,
|
||
settings_page,
|
||
)
|
||
text_lines = [
|
||
f'🗂 <b>{category_label}</b>',
|
||
f'🏠 → {group_plain_title} → {category_label}',
|
||
]
|
||
if category_description:
|
||
text_lines.append(category_description)
|
||
text_lines.append('')
|
||
text_lines.append('📋 Список настроек категории:')
|
||
await callback.message.edit_text(
|
||
'\n'.join(text_lines),
|
||
reply_markup=keyboard,
|
||
parse_mode='HTML',
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_simple_subscription_squad_selector(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
parts = callback.data.split(':', 5)
|
||
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:
|
||
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
|
||
|
||
if key != 'SIMPLE_SUBSCRIPTION_SQUAD_UUID':
|
||
await callback.answer('Эта настройка больше недоступна', show_alert=True)
|
||
return
|
||
|
||
try:
|
||
page = max(1, int(parts[5])) if len(parts) > 5 else 1
|
||
except ValueError:
|
||
page = 1
|
||
|
||
limit = SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE
|
||
squads, total_count = await get_all_server_squads(
|
||
db,
|
||
available_only=False,
|
||
page=page,
|
||
limit=limit,
|
||
)
|
||
|
||
total_count = total_count or 0
|
||
total_pages = max(1, math.ceil(total_count / limit)) if total_count else 1
|
||
if total_count and page > total_pages:
|
||
page = total_pages
|
||
squads, total_count = await get_all_server_squads(
|
||
db,
|
||
available_only=False,
|
||
page=page,
|
||
limit=limit,
|
||
)
|
||
|
||
current_uuid = bot_configuration_service.get_current_value(key) or ''
|
||
current_display = 'Любой доступный'
|
||
|
||
if current_uuid:
|
||
selected_server = next((srv for srv in squads if srv.squad_uuid == current_uuid), None)
|
||
if not selected_server:
|
||
selected_server = await get_server_squad_by_uuid(db, current_uuid)
|
||
if selected_server:
|
||
current_display = selected_server.display_name
|
||
else:
|
||
current_display = current_uuid
|
||
|
||
lines = [
|
||
'🌍 <b>Выберите сквад для простой покупки</b>',
|
||
'',
|
||
f'Текущий выбор: {html.escape(current_display)}' if current_display else 'Текущий выбор: —',
|
||
'',
|
||
]
|
||
|
||
if total_count == 0:
|
||
lines.append('❌ Доступные сервера не найдены.')
|
||
else:
|
||
lines.append('Выберите сервер из списка ниже.')
|
||
if total_pages > 1:
|
||
lines.append(f'Страница {page}/{total_pages}')
|
||
|
||
text = '\n'.join(lines)
|
||
|
||
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
|
||
|
||
for server in squads:
|
||
status_icon = '✅' if server.squad_uuid == current_uuid else ('🟢' if server.is_available else '🔒')
|
||
label_parts = [status_icon, server.display_name]
|
||
if server.country_code:
|
||
label_parts.append(f'({server.country_code.upper()})')
|
||
if isinstance(server.price_kopeks, int) and server.price_kopeks > 0:
|
||
try:
|
||
label_parts.append(f'— {settings.format_price(server.price_kopeks)}')
|
||
except Exception:
|
||
pass
|
||
label = ' '.join(label_parts)
|
||
|
||
keyboard_rows.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=label,
|
||
callback_data=(
|
||
f'botcfg_simple_squad_select:{group_key}:{category_page}:{settings_page}:{token}:{server.id}:{page}'
|
||
),
|
||
)
|
||
]
|
||
)
|
||
|
||
if total_pages > 1:
|
||
nav_row: list[types.InlineKeyboardButton] = []
|
||
if page > 1:
|
||
nav_row.append(
|
||
types.InlineKeyboardButton(
|
||
text='⬅️',
|
||
callback_data=(
|
||
f'botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page - 1}'
|
||
),
|
||
)
|
||
)
|
||
if page < total_pages:
|
||
nav_row.append(
|
||
types.InlineKeyboardButton(
|
||
text='➡️',
|
||
callback_data=(
|
||
f'botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page + 1}'
|
||
),
|
||
)
|
||
)
|
||
if nav_row:
|
||
keyboard_rows.append(nav_row)
|
||
|
||
keyboard_rows.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='⬅️ Назад',
|
||
callback_data=(f'botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}'),
|
||
)
|
||
]
|
||
)
|
||
|
||
await callback.message.edit_text(
|
||
text,
|
||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||
parse_mode='HTML',
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def select_simple_subscription_squad(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
parts = callback.data.split(':', 6)
|
||
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:
|
||
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:
|
||
server_id = int(parts[5]) if len(parts) > 5 else None
|
||
except ValueError:
|
||
server_id = None
|
||
|
||
if server_id is None:
|
||
await callback.answer('Не удалось определить сервер', show_alert=True)
|
||
return
|
||
|
||
try:
|
||
key = bot_configuration_service.resolve_callback_token(token)
|
||
except KeyError:
|
||
await callback.answer('Эта настройка больше недоступна', show_alert=True)
|
||
return
|
||
|
||
if bot_configuration_service.is_read_only(key):
|
||
await callback.answer('Эта настройка доступна только для чтения', show_alert=True)
|
||
return
|
||
|
||
server = await get_server_squad_by_id(db, server_id)
|
||
if not server:
|
||
await callback.answer('Сервер не найден', show_alert=True)
|
||
return
|
||
|
||
try:
|
||
await bot_configuration_service.set_value(db, key, server.squad_uuid)
|
||
except ReadOnlySettingError:
|
||
await callback.answer('Эта настройка доступна только для чтения', show_alert=True)
|
||
return
|
||
|
||
await db.commit()
|
||
|
||
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=group_key,
|
||
category_page=category_page,
|
||
settings_page=settings_page,
|
||
)
|
||
await callback.answer('Сквад выбран')
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def test_remnawave_connection(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
):
|
||
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'
|
||
|
||
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()
|
||
|
||
status = result.get('status')
|
||
message: str
|
||
|
||
if status == 'connected':
|
||
message = '✅ Подключение успешно'
|
||
elif status == 'not_configured':
|
||
message = f'⚠️ {result.get("message", "RemnaWave API не настроен")}'
|
||
else:
|
||
base_message = result.get('message', 'Ошибка подключения')
|
||
status_code = result.get('status_code')
|
||
if status_code:
|
||
message = f'❌ {base_message} (HTTP {status_code})'
|
||
else:
|
||
message = f'❌ {base_message}'
|
||
|
||
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)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def test_payment_provider(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
):
|
||
parts = callback.data.split(':', 6)
|
||
method = parts[1] if len(parts) > 1 else ''
|
||
group_key = parts[2] if len(parts) > 2 else CATEGORY_FALLBACK_KEY
|
||
category_key = parts[3] if len(parts) > 3 else 'PAYMENT'
|
||
|
||
try:
|
||
category_page = max(1, int(parts[4])) if len(parts) > 4 else 1
|
||
except ValueError:
|
||
category_page = 1
|
||
|
||
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)
|
||
payment_service = PaymentService(callback.bot)
|
||
|
||
message_text: str
|
||
|
||
async def _refresh_markup() -> None:
|
||
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():
|
||
await callback.answer('❌ YooKassa отключена', show_alert=True)
|
||
return
|
||
|
||
amount_kopeks = 10 * 100
|
||
description = (settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),)
|
||
payment_result = await payment_service.create_yookassa_payment(
|
||
db=db,
|
||
user_id=db_user.id,
|
||
amount_kopeks=amount_kopeks,
|
||
description=f'Тестовый платеж (админ): {description}',
|
||
metadata={
|
||
'user_telegram_id': str(db_user.telegram_id),
|
||
'purpose': 'admin_test_payment',
|
||
'provider': 'yookassa',
|
||
},
|
||
)
|
||
|
||
if not payment_result or not payment_result.get('confirmation_url'):
|
||
await callback.answer('❌ Не удалось создать тестовый платеж YooKassa', show_alert=True)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
confirmation_url = payment_result['confirmation_url']
|
||
message_text = (
|
||
'🧪 <b>Тестовый платеж YooKassa</b>\n\n'
|
||
f'💰 Сумма: {texts.format_price(amount_kopeks)}\n'
|
||
f'🆔 ID: {payment_result["yookassa_payment_id"]}'
|
||
)
|
||
reply_markup = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='💳 Оплатить картой',
|
||
url=confirmation_url,
|
||
)
|
||
],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='📊 Проверить статус',
|
||
callback_data=f'check_yookassa_{payment_result["local_payment_id"]}',
|
||
)
|
||
],
|
||
]
|
||
)
|
||
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML')
|
||
await callback.answer('✅ Ссылка на платеж YooKassa отправлена', show_alert=True)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
if method == 'tribute':
|
||
if not settings.TRIBUTE_ENABLED:
|
||
await callback.answer('❌ Tribute отключен', show_alert=True)
|
||
return
|
||
|
||
tribute_service = TributeService(callback.bot)
|
||
try:
|
||
payment_url = await tribute_service.create_payment_link(
|
||
user_id=db_user.telegram_id,
|
||
amount_kopeks=10 * 100,
|
||
description='Тестовый платеж Tribute (админ)',
|
||
)
|
||
except Exception:
|
||
payment_url = None
|
||
|
||
if not payment_url:
|
||
await callback.answer('❌ Не удалось создать платеж Tribute', show_alert=True)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
message_text = (
|
||
'🧪 <b>Тестовый платеж Tribute</b>\n\n'
|
||
f'💰 Сумма: {texts.format_price(10 * 100)}\n'
|
||
'🔗 Нажмите кнопку ниже, чтобы открыть ссылку на оплату.'
|
||
)
|
||
reply_markup = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='💳 Перейти к оплате',
|
||
url=payment_url,
|
||
)
|
||
]
|
||
]
|
||
)
|
||
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML')
|
||
await callback.answer('✅ Ссылка на платеж Tribute отправлена', show_alert=True)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
if method == 'mulenpay':
|
||
mulenpay_name = settings.get_mulenpay_display_name()
|
||
mulenpay_name_html = settings.get_mulenpay_display_name_html()
|
||
if not settings.is_mulenpay_enabled():
|
||
await callback.answer(
|
||
f'❌ {mulenpay_name} отключен',
|
||
show_alert=True,
|
||
)
|
||
return
|
||
|
||
amount_kopeks = 1 * 100
|
||
payment_result = await payment_service.create_mulenpay_payment(
|
||
db=db,
|
||
user_id=db_user.id,
|
||
amount_kopeks=amount_kopeks,
|
||
description=f'Тестовый платеж {mulenpay_name} (админ)',
|
||
language=language,
|
||
)
|
||
|
||
if not payment_result or not payment_result.get('payment_url'):
|
||
await callback.answer(
|
||
f'❌ Не удалось создать платеж {mulenpay_name}',
|
||
show_alert=True,
|
||
)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
payment_url = payment_result['payment_url']
|
||
message_text = (
|
||
f'🧪 <b>Тестовый платеж {mulenpay_name_html}</b>\n\n'
|
||
f'💰 Сумма: {texts.format_price(amount_kopeks)}\n'
|
||
f'🆔 ID: {payment_result["mulen_payment_id"]}'
|
||
)
|
||
reply_markup = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='💳 Перейти к оплате',
|
||
url=payment_url,
|
||
)
|
||
],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='📊 Проверить статус',
|
||
callback_data=f'check_mulenpay_{payment_result["local_payment_id"]}',
|
||
)
|
||
],
|
||
]
|
||
)
|
||
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML')
|
||
await callback.answer(
|
||
f'✅ Ссылка на платеж {mulenpay_name} отправлена',
|
||
show_alert=True,
|
||
)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
if method == 'pal24':
|
||
if not settings.is_pal24_enabled():
|
||
await callback.answer('❌ PayPalych отключен', show_alert=True)
|
||
return
|
||
|
||
amount_kopeks = 10 * 100
|
||
payment_result = await payment_service.create_pal24_payment(
|
||
db=db,
|
||
user_id=db_user.id,
|
||
amount_kopeks=amount_kopeks,
|
||
description='Тестовый платеж PayPalych (админ)',
|
||
language=language or 'ru',
|
||
)
|
||
|
||
if not payment_result:
|
||
await callback.answer('❌ Не удалось создать платеж PayPalych', show_alert=True)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
sbp_url = payment_result.get('sbp_url') or payment_result.get('transfer_url') or payment_result.get('link_url')
|
||
card_url = payment_result.get('card_url')
|
||
fallback_url = payment_result.get('link_page_url') or payment_result.get('link_url')
|
||
|
||
if not (sbp_url or card_url or fallback_url):
|
||
await callback.answer('❌ Не удалось создать платеж PayPalych', show_alert=True)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
if not sbp_url:
|
||
sbp_url = fallback_url
|
||
|
||
default_sbp_text = texts.t(
|
||
'PAL24_SBP_PAY_BUTTON',
|
||
'🏦 Оплатить через PayPalych (СБП)',
|
||
)
|
||
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
|
||
|
||
default_card_text = texts.t(
|
||
'PAL24_CARD_PAY_BUTTON',
|
||
'💳 Оплатить банковской картой (PayPalych)',
|
||
)
|
||
card_button_text = settings.get_pal24_card_button_text(default_card_text)
|
||
|
||
pay_rows: list[list[types.InlineKeyboardButton]] = []
|
||
if sbp_url:
|
||
pay_rows.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=sbp_button_text,
|
||
url=sbp_url,
|
||
)
|
||
]
|
||
)
|
||
|
||
if card_url and card_url != sbp_url:
|
||
pay_rows.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=card_button_text,
|
||
url=card_url,
|
||
)
|
||
]
|
||
)
|
||
|
||
if not pay_rows and fallback_url:
|
||
pay_rows.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=sbp_button_text,
|
||
url=fallback_url,
|
||
)
|
||
]
|
||
)
|
||
|
||
message_text = (
|
||
'🧪 <b>Тестовый платеж PayPalych</b>\n\n'
|
||
f'💰 Сумма: {texts.format_price(amount_kopeks)}\n'
|
||
f'🆔 Bill ID: {payment_result["bill_id"]}'
|
||
)
|
||
keyboard_rows = pay_rows + [
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='📊 Проверить статус',
|
||
callback_data=f'check_pal24_{payment_result["local_payment_id"]}',
|
||
)
|
||
],
|
||
]
|
||
|
||
reply_markup = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML')
|
||
await callback.answer('✅ Ссылка на платеж PayPalych отправлена', show_alert=True)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
if method == 'stars':
|
||
if not settings.TELEGRAM_STARS_ENABLED:
|
||
await callback.answer('❌ Telegram Stars отключены', show_alert=True)
|
||
return
|
||
|
||
stars_rate = settings.get_stars_rate()
|
||
amount_kopeks = max(1, int(round(stars_rate * 100)))
|
||
payload = f'admin_stars_test_{db_user.id}_{int(time.time())}'
|
||
try:
|
||
invoice_link = await payment_service.create_stars_invoice(
|
||
amount_kopeks=amount_kopeks,
|
||
description='Тестовый платеж Telegram Stars (админ)',
|
||
payload=payload,
|
||
)
|
||
except Exception:
|
||
invoice_link = None
|
||
|
||
if not invoice_link:
|
||
await callback.answer('❌ Не удалось создать платеж Telegram Stars', show_alert=True)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_kopeks / 100)
|
||
message_text = (
|
||
'🧪 <b>Тестовый платеж Telegram Stars</b>\n\n'
|
||
f'💰 Сумма: {texts.format_price(amount_kopeks)}\n'
|
||
f'⭐ К оплате: {stars_amount}'
|
||
)
|
||
reply_markup = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t('PAYMENT_TELEGRAM_STARS', '⭐ Открыть счет'),
|
||
url=invoice_link,
|
||
)
|
||
]
|
||
]
|
||
)
|
||
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML')
|
||
await callback.answer('✅ Ссылка на платеж Stars отправлена', show_alert=True)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
if method == 'cryptobot':
|
||
if not settings.is_cryptobot_enabled():
|
||
await callback.answer('❌ CryptoBot отключен', show_alert=True)
|
||
return
|
||
|
||
amount_rubles = 100.0
|
||
try:
|
||
current_rate = await currency_converter.get_usd_to_rub_rate()
|
||
except Exception:
|
||
current_rate = None
|
||
|
||
if not current_rate or current_rate <= 0:
|
||
current_rate = 100.0
|
||
|
||
amount_usd = round(amount_rubles / current_rate, 2)
|
||
if amount_usd < 1:
|
||
amount_usd = 1.0
|
||
|
||
payment_result = await payment_service.create_cryptobot_payment(
|
||
db=db,
|
||
user_id=db_user.id,
|
||
amount_usd=amount_usd,
|
||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||
description=f'Тестовый платеж CryptoBot {amount_rubles:.0f} ₽ ({amount_usd:.2f} USD)',
|
||
payload=f'admin_cryptobot_test_{db_user.id}_{int(time.time())}',
|
||
)
|
||
|
||
if not payment_result:
|
||
await callback.answer('❌ Не удалось создать платеж CryptoBot', show_alert=True)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
payment_url = (
|
||
payment_result.get('bot_invoice_url')
|
||
or payment_result.get('mini_app_invoice_url')
|
||
or payment_result.get('web_app_invoice_url')
|
||
)
|
||
|
||
if not payment_url:
|
||
await callback.answer('❌ Не удалось получить ссылку на оплату CryptoBot', show_alert=True)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
amount_kopeks = int(amount_rubles * 100)
|
||
message_text = (
|
||
'🧪 <b>Тестовый платеж CryptoBot</b>\n\n'
|
||
f'💰 Сумма к зачислению: {texts.format_price(amount_kopeks)}\n'
|
||
f'💵 К оплате: {amount_usd:.2f} USD\n'
|
||
f'🪙 Актив: {payment_result["asset"]}'
|
||
)
|
||
reply_markup = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[types.InlineKeyboardButton(text='🪙 Открыть счет', url=payment_url)],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='📊 Проверить статус',
|
||
callback_data=f'check_cryptobot_{payment_result["local_payment_id"]}',
|
||
)
|
||
],
|
||
]
|
||
)
|
||
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML')
|
||
await callback.answer('✅ Ссылка на платеж CryptoBot отправлена', show_alert=True)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
if method == 'freekassa':
|
||
if not settings.is_freekassa_enabled():
|
||
await callback.answer('❌ Freekassa отключена', show_alert=True)
|
||
return
|
||
|
||
amount_kopeks = settings.FREEKASSA_MIN_AMOUNT_KOPEKS
|
||
payment_result = await payment_service.create_freekassa_payment(
|
||
db=db,
|
||
user_id=db_user.id,
|
||
amount_kopeks=amount_kopeks,
|
||
description='Тестовый платеж Freekassa (админ)',
|
||
email=getattr(db_user, 'email', None),
|
||
language=db_user.language or settings.DEFAULT_LANGUAGE,
|
||
)
|
||
|
||
if not payment_result or not payment_result.get('payment_url'):
|
||
await callback.answer('❌ Не удалось создать тестовый платеж Freekassa', show_alert=True)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
payment_url = payment_result['payment_url']
|
||
message_text = (
|
||
'🧪 <b>Тестовый платеж Freekassa</b>\n\n'
|
||
f'💰 Сумма: {texts.format_price(amount_kopeks)}\n'
|
||
f'🆔 Order ID: {payment_result["order_id"]}'
|
||
)
|
||
reply_markup = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='💳 Перейти к оплате',
|
||
url=payment_url,
|
||
)
|
||
]
|
||
]
|
||
)
|
||
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML')
|
||
await callback.answer('✅ Ссылка на платеж Freekassa отправлена', show_alert=True)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
if method == 'kassa_ai':
|
||
if not settings.is_kassa_ai_enabled():
|
||
await callback.answer('❌ Kassa AI отключена', show_alert=True)
|
||
return
|
||
|
||
amount_kopeks = settings.KASSA_AI_MIN_AMOUNT_KOPEKS
|
||
payment_result = await payment_service.create_kassa_ai_payment(
|
||
db=db,
|
||
user_id=db_user.id,
|
||
amount_kopeks=amount_kopeks,
|
||
description='Тестовый платеж Kassa AI (админ)',
|
||
email=getattr(db_user, 'email', None),
|
||
language=db_user.language or settings.DEFAULT_LANGUAGE,
|
||
)
|
||
|
||
if not payment_result or not payment_result.get('payment_url'):
|
||
await callback.answer('❌ Не удалось создать тестовый платеж Kassa AI', show_alert=True)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
payment_url = payment_result['payment_url']
|
||
display_name = settings.get_kassa_ai_display_name()
|
||
message_text = (
|
||
f'🧪 <b>Тестовый платеж {display_name}</b>\n\n'
|
||
f'💰 Сумма: {texts.format_price(amount_kopeks)}\n'
|
||
f'🆔 Order ID: {payment_result["order_id"]}'
|
||
)
|
||
reply_markup = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='💳 Перейти к оплате',
|
||
url=payment_url,
|
||
)
|
||
]
|
||
]
|
||
)
|
||
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML')
|
||
await callback.answer(f'✅ Ссылка на платеж {display_name} отправлена', show_alert=True)
|
||
await _refresh_markup()
|
||
return
|
||
|
||
await callback.answer('❌ Неизвестный способ тестирования платежа', show_alert=True)
|
||
await _refresh_markup()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_bot_config_setting(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
parts = callback.data.split(':', 4)
|
||
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:
|
||
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
|
||
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=group_key,
|
||
category_page=category_page,
|
||
settings_page=settings_page,
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def start_edit_setting(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
parts = callback.data.split(':', 4)
|
||
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:
|
||
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
|
||
if bot_configuration_service.is_read_only(key):
|
||
await callback.answer('Эта настройка доступна только для чтения', show_alert=True)
|
||
return
|
||
definition = bot_configuration_service.get_definition(key)
|
||
|
||
summary = bot_configuration_service.get_setting_summary(key)
|
||
texts = get_texts(db_user.language)
|
||
|
||
instructions = [
|
||
'✏️ <b>Редактирование настройки</b>',
|
||
f'Название: {summary["name"]}',
|
||
f'Ключ: <code>{summary["key"]}</code>',
|
||
f'Тип: {summary["type"]}',
|
||
f'Текущее значение: {summary["current"]}',
|
||
'\nОтправьте новое значение сообщением.',
|
||
]
|
||
|
||
if definition.is_optional:
|
||
instructions.append("Отправьте 'none' или оставьте пустым для сброса на значение по умолчанию.")
|
||
|
||
instructions.append("Для отмены отправьте 'cancel'.")
|
||
|
||
await callback.message.edit_text(
|
||
'\n'.join(instructions),
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.BACK,
|
||
callback_data=(f'botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}'),
|
||
)
|
||
]
|
||
]
|
||
),
|
||
)
|
||
|
||
await _store_setting_context(
|
||
state,
|
||
key=key,
|
||
group_key=group_key,
|
||
category_page=category_page,
|
||
settings_page=settings_page,
|
||
)
|
||
await state.set_state(BotConfigStates.waiting_for_value)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def handle_edit_setting(
|
||
message: types.Message,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
data = await state.get_data()
|
||
key = data.get('setting_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)
|
||
|
||
if not key:
|
||
await message.answer('Не удалось определить редактируемую настройку. Попробуйте снова.')
|
||
await state.clear()
|
||
return
|
||
|
||
if bot_configuration_service.is_read_only(key):
|
||
await message.answer('⚠️ Эта настройка доступна только для чтения.')
|
||
await state.clear()
|
||
return
|
||
|
||
try:
|
||
value = bot_configuration_service.parse_user_value(key, message.text or '')
|
||
except ValueError as error:
|
||
await message.answer(f'⚠️ {error}')
|
||
return
|
||
|
||
try:
|
||
await bot_configuration_service.set_value(db, key, value)
|
||
except ReadOnlySettingError:
|
||
await message.answer('⚠️ Эта настройка доступна только для чтения.')
|
||
await state.clear()
|
||
return
|
||
await db.commit()
|
||
|
||
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)
|
||
await state.clear()
|
||
await _store_setting_context(
|
||
state,
|
||
key=key,
|
||
group_key=group_key,
|
||
category_page=category_page,
|
||
settings_page=settings_page,
|
||
)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def handle_direct_setting_input(
|
||
message: types.Message,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
data = await state.get_data()
|
||
|
||
key = data.get('setting_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)
|
||
|
||
if not key:
|
||
return
|
||
|
||
if bot_configuration_service.is_read_only(key):
|
||
await message.answer('⚠️ Эта настройка доступна только для чтения.')
|
||
await state.clear()
|
||
return
|
||
|
||
try:
|
||
value = bot_configuration_service.parse_user_value(key, message.text or '')
|
||
except ValueError as error:
|
||
await message.answer(f'⚠️ {error}')
|
||
return
|
||
|
||
try:
|
||
await bot_configuration_service.set_value(db, key, value)
|
||
except ReadOnlySettingError:
|
||
await message.answer('⚠️ Эта настройка доступна только для чтения.')
|
||
await state.clear()
|
||
return
|
||
await db.commit()
|
||
|
||
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)
|
||
|
||
await state.clear()
|
||
await _store_setting_context(
|
||
state,
|
||
key=key,
|
||
group_key=group_key,
|
||
category_page=category_page,
|
||
settings_page=settings_page,
|
||
)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def reset_setting(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
parts = callback.data.split(':', 4)
|
||
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:
|
||
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
|
||
if bot_configuration_service.is_read_only(key):
|
||
await callback.answer('Эта настройка доступна только для чтения', show_alert=True)
|
||
return
|
||
try:
|
||
await bot_configuration_service.reset_value(db, key)
|
||
except ReadOnlySettingError:
|
||
await callback.answer('Эта настройка доступна только для чтения', show_alert=True)
|
||
return
|
||
await db.commit()
|
||
|
||
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=group_key,
|
||
category_page=category_page,
|
||
settings_page=settings_page,
|
||
)
|
||
await callback.answer('Сброшено к значению по умолчанию')
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def toggle_setting(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
parts = callback.data.split(':', 4)
|
||
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:
|
||
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
|
||
if bot_configuration_service.is_read_only(key):
|
||
await callback.answer('Эта настройка доступна только для чтения', show_alert=True)
|
||
return
|
||
current = bot_configuration_service.get_current_value(key)
|
||
new_value = not bool(current)
|
||
try:
|
||
await bot_configuration_service.set_value(db, key, new_value)
|
||
except ReadOnlySettingError:
|
||
await callback.answer('Эта настройка доступна только для чтения', show_alert=True)
|
||
return
|
||
await db.commit()
|
||
|
||
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=group_key,
|
||
category_page=category_page,
|
||
settings_page=settings_page,
|
||
)
|
||
await callback.answer('Обновлено')
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def apply_setting_choice(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
parts = callback.data.split(':', 5)
|
||
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:
|
||
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 ''
|
||
choice_token = parts[5] if len(parts) > 5 else ''
|
||
|
||
try:
|
||
key = bot_configuration_service.resolve_callback_token(token)
|
||
except KeyError:
|
||
await callback.answer('Эта настройка больше недоступна', show_alert=True)
|
||
return
|
||
if bot_configuration_service.is_read_only(key):
|
||
await callback.answer('Эта настройка доступна только для чтения', show_alert=True)
|
||
return
|
||
|
||
try:
|
||
value = bot_configuration_service.resolve_choice_token(key, choice_token)
|
||
except KeyError:
|
||
await callback.answer('Это значение больше недоступно', show_alert=True)
|
||
return
|
||
|
||
try:
|
||
await bot_configuration_service.set_value(db, key, value)
|
||
except ReadOnlySettingError:
|
||
await callback.answer('Эта настройка доступна только для чтения', show_alert=True)
|
||
return
|
||
await db.commit()
|
||
|
||
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=group_key,
|
||
category_page=category_page,
|
||
settings_page=settings_page,
|
||
)
|
||
await callback.answer('Значение обновлено')
|
||
|
||
|
||
def register_handlers(dp: Dispatcher) -> None:
|
||
dp.callback_query.register(
|
||
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')),
|
||
)
|
||
dp.callback_query.register(
|
||
show_bot_config_category,
|
||
F.data.startswith('botcfg_cat:'),
|
||
)
|
||
dp.callback_query.register(
|
||
test_remnawave_connection,
|
||
F.data.startswith('botcfg_test_remnawave:'),
|
||
)
|
||
dp.callback_query.register(
|
||
test_payment_provider,
|
||
F.data.startswith('botcfg_test_payment:'),
|
||
)
|
||
dp.callback_query.register(
|
||
select_simple_subscription_squad,
|
||
F.data.startswith('botcfg_simple_squad_select:'),
|
||
)
|
||
dp.callback_query.register(
|
||
show_simple_subscription_squad_selector,
|
||
F.data.startswith('botcfg_simple_squad:'),
|
||
)
|
||
dp.callback_query.register(
|
||
show_bot_config_setting,
|
||
F.data.startswith('botcfg_setting:'),
|
||
)
|
||
dp.callback_query.register(
|
||
start_edit_setting,
|
||
F.data.startswith('botcfg_edit:'),
|
||
)
|
||
dp.callback_query.register(
|
||
reset_setting,
|
||
F.data.startswith('botcfg_reset:'),
|
||
)
|
||
dp.callback_query.register(
|
||
toggle_setting,
|
||
F.data.startswith('botcfg_toggle:'),
|
||
)
|
||
dp.callback_query.register(
|
||
apply_setting_choice,
|
||
F.data.startswith('botcfg_choice:'),
|
||
)
|
||
dp.message.register(
|
||
handle_direct_setting_input,
|
||
StateFilter(None),
|
||
F.text,
|
||
BotConfigInputFilter(),
|
||
)
|
||
dp.message.register(
|
||
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,
|
||
)
|