from __future__ import annotations import html import secrets from pathlib import Path from urllib.parse import quote_plus from aiogram import Bot, F, Router from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.filters import Command, CommandObject, CommandStart from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, Message from aiogram.utils.keyboard import InlineKeyboardBuilder from app.bot.ui.panel import ( PANEL_SECTION_ADMIN, PANEL_SECTION_HOME, PANEL_SECTION_REFERRAL, PANEL_SECTION_SUBSCRIPTION, PANEL_SECTION_SUPPORT, PANEL_SECTION_SUPPORT_TICKET, PANEL_SECTION_TERMS, PanelContext, build_panel_keyboard, render_panel_caption, ) from app.config import Settings from app.services.bot_config_service import BotConfigService, BotConfigSnapshot, StaticBotConfigService from app.services.payment_service import ( FULFILLED_PAYMENT_STATUS, PENDING_PAYMENT_STATUS, REJECTED_PAYMENT_STATUS, REVIEW_PAYMENT_STATUS, CreatedPaymentOrder, PaymentPlan, PaymentService, StoredPaymentOrder, ) from app.services.remnawave_client import RemnawaveApiError from app.services.support_ticket_service import SupportTicketService from app.services.sync_service import ( AdminTelegramUserProfile, AdminTelegramUsersPage, CachedUserView, SyncService, ) from app.utils.formatters import ( build_help_text, format_bytes, format_datetime, format_datetime_with_days_left, format_issued_access, format_traffic_limit, format_user_card, ) class SupportTicketStates(StatesGroup): waiting_for_message = State() class ReferralCodeStates(StatesGroup): waiting_for_code = State() class ManualPaymentStates(StatesGroup): waiting_for_receipt = State() class AdminLookupStates(StatesGroup): waiting_for_identifier = State() class AdminConfigStates(StatesGroup): waiting_for_value = State() def build_router( *, settings: Settings, sync_service: SyncService, support_ticket_service: SupportTicketService, payment_service: PaymentService, bot_config_service: StaticBotConfigService, ) -> Router: router = Router(name="main") project_root = Path(__file__).resolve().parents[3] default_start_image_path = project_root / "assets" / "main.png" async def get_runtime_config() -> BotConfigSnapshot: return await bot_config_service.get_snapshot() def resolve_start_image_path(raw_path: str) -> Path | None: if not raw_path.strip(): return default_start_image_path if default_start_image_path.is_file() else None image_path = Path(raw_path).expanduser() if not image_path.is_absolute(): image_path = project_root / image_path return image_path if image_path.is_file() else None def get_start_image_path(config: BotConfigSnapshot) -> Path | None: if not config.bot_start_image_enabled: return None return resolve_start_image_path(config.bot_start_image_path) def payment_callback(action: str, value: str | None = None) -> str: return f"payment:{action}:{value}" if value else f"payment:{action}" async def send_branded( bot_instance: Bot, *, chat_id: int, text: str, reply_markup=None, ) -> None: """Send a message with brand image if enabled and caption fits.""" config = await get_runtime_config() start_image_path = get_start_image_path(config) if start_image_path is not None and len(text) <= 1024: await bot_instance.send_photo( chat_id=chat_id, photo=FSInputFile(str(start_image_path)), caption=text, reply_markup=reply_markup, ) return await bot_instance.send_message( chat_id=chat_id, text=text, reply_markup=reply_markup, ) async def answer_branded( message: Message, text: str, reply_markup=None, ) -> None: """Answer message with brand image if enabled and caption fits.""" config = await get_runtime_config() start_image_path = get_start_image_path(config) if start_image_path is not None and len(text) <= 1024: await message.answer_photo( FSInputFile(str(start_image_path)), caption=text, reply_markup=reply_markup, ) return await message.answer(text, reply_markup=reply_markup) async def show_branded_view( callback: CallbackQuery, *, text: str, reply_markup, ) -> None: """Show a branded text view — new message with image if possible.""" if callback.message is None: await callback.answer() return config = await get_runtime_config() start_image_path = get_start_image_path(config) if start_image_path is not None and len(text) <= 1024: await callback.message.answer_photo( FSInputFile(str(start_image_path)), caption=text, reply_markup=reply_markup, ) return try: if callback.message.photo or callback.message.document: await callback.message.answer(text, reply_markup=reply_markup) else: await callback.message.edit_text(text, reply_markup=reply_markup) except TelegramBadRequest as exc: if "message is not modified" not in str(exc).lower(): raise async def ensure_sender(message: Message) -> bool: if message.from_user is None: return False if await sync_service.is_user_blocked(message.from_user.id): await message.answer( "\u26d4 Ваш аккаунт заблокирован. Обратитесь в поддержку." ) return False return True async def clear_interactive_states(state: FSMContext) -> None: if await state.get_state() in { SupportTicketStates.waiting_for_message.state, ReferralCodeStates.waiting_for_code.state, ManualPaymentStates.waiting_for_receipt.state, AdminLookupStates.waiting_for_identifier.state, AdminConfigStates.waiting_for_value.state, }: await state.clear() async def load_panel_context( *, telegram_id: int, username: str | None, first_name: str | None, last_name: str | None, language_code: str | None, sync_remote: bool, ) -> PanelContext: users: list[CachedUserView] = [] api_error: str | None = None is_admin = settings.is_admin(telegram_id) config = await get_runtime_config() if sync_remote: try: await payment_service.apply_pending_referral_bonuses( telegram_id=telegram_id, refresh_cache=False, ) except RemnawaveApiError: pass try: users = await sync_service.sync_users_for_telegram( telegram_id=telegram_id, username=username, first_name=first_name, last_name=last_name, language_code=language_code, ) except RemnawaveApiError as exc: api_error = exc.message users = await sync_service.get_cached_users_for_telegram(telegram_id) else: users = await sync_service.get_cached_users_for_telegram(telegram_id) display_name = " ".join(part for part in [first_name, last_name] if part).strip() if not display_name: display_name = username or "Пользователь" referral_summary = await sync_service.get_referral_summary(telegram_id) referral_link = "" referral_share_url = "" if config.referral_enabled and config.bot_public_username_normalized: referral_link = ( f"https://t.me/{config.bot_public_username_normalized}" f"?start=ref_{referral_summary.referral_code}" ) share_text = ( "Вас пригласили по реферальной ссылке. " f"Перед оплатой введите код {referral_summary.referral_code} " f"и получите скидку {max(config.referral_discount_percent, 0)}%." ) referral_share_url = ( "https://t.me/share/url" f"?url={quote_plus(referral_link)}" f"&text={quote_plus(share_text)}" ) plans = await payment_service.get_available_plans(telegram_id=telegram_id) primary_plan = plans[0] if plans else None admin_total_telegram_users = 0 admin_total_cached_users = 0 admin_active_cached_users = 0 admin_pending_orders = 0 admin_review_orders = 0 admin_open_tickets = 0 admin_pending_referral_bonuses = 0 if is_admin: admin_stats = await sync_service.get_admin_stats() payment_admin_stats = await payment_service.get_admin_stats() admin_total_telegram_users = admin_stats.total_telegram_users admin_total_cached_users = admin_stats.total_cached_users admin_active_cached_users = admin_stats.active_cached_users admin_pending_orders = payment_admin_stats.pending_orders admin_review_orders = payment_admin_stats.review_orders admin_pending_referral_bonuses = payment_admin_stats.pending_referral_bonuses admin_open_tickets = await support_ticket_service.get_open_tickets_count() return PanelContext( telegram_id=telegram_id, display_name=display_name, username=username, language_code=language_code, is_admin=is_admin, users=users, referral_enabled=config.referral_enabled, referral_code=referral_summary.referral_code, referral_count=referral_summary.total_invited, applied_referral_code=( referral_summary.applied_referral_code if config.referral_enabled else "" ), referral_link=referral_link, referral_share_url=referral_share_url, referral_recent_names=referral_summary.recent_names, referral_discount_percent=max(config.referral_discount_percent, 0), referral_bonus_days=max(config.referral_bonus_days, 0), admin_total_telegram_users=admin_total_telegram_users, admin_total_cached_users=admin_total_cached_users, admin_active_cached_users=admin_active_cached_users, admin_pending_orders=admin_pending_orders, admin_review_orders=admin_review_orders, admin_open_tickets=admin_open_tickets, admin_pending_referral_bonuses=admin_pending_referral_bonuses, payment_enabled=bool(primary_plan and primary_plan.is_ready), payment_plan_title=primary_plan.title if primary_plan else "", payment_plan_description=primary_plan.description if primary_plan else "", payment_plan_price_stars=primary_plan.amount_rub if primary_plan else 0, payment_plan_duration_days=primary_plan.duration_days if primary_plan else 0, payment_plan_traffic_limit_bytes=primary_plan.traffic_limit_bytes if primary_plan else 0, api_error=api_error, ) def parse_start_referral_code(message: Message) -> str | None: text = (message.text or "").strip() parts = text.split(maxsplit=1) if len(parts) < 2: return None arg = parts[1].strip() if not arg.startswith("ref_"): return None raw_value = arg.removeprefix("ref_").strip().upper() return raw_value if raw_value else None async def show_panel( message: Message, *, context: PanelContext, section: str, ) -> None: config = await get_runtime_config() caption = render_panel_caption( section=section, context=context, brand_name=config.bot_brand_name, ) keyboard = build_panel_keyboard( section=section, context=context, support_url=config.bot_support_url, terms_url=config.bot_terms_url, payment_review_url=config.payment_review_link, support_ticket_url=config.bot_support_ticket_link, ) start_image_path = get_start_image_path(config) if start_image_path is not None: await message.answer_photo( FSInputFile(str(start_image_path)), caption=caption, reply_markup=keyboard, ) return await message.answer(caption, reply_markup=keyboard) async def edit_message_content( callback: CallbackQuery, *, text: str, reply_markup, ) -> None: if callback.message is None: await callback.answer() return try: if callback.message.photo or callback.message.document: await callback.message.edit_caption(caption=text, reply_markup=reply_markup) else: await callback.message.edit_text(text, reply_markup=reply_markup) except TelegramBadRequest as exc: if "message is not modified" not in str(exc).lower(): raise async def show_text_view( callback: CallbackQuery, *, text: str, reply_markup, ) -> None: if callback.message is None: await callback.answer() return try: if callback.message.photo or callback.message.document: await callback.message.answer(text, reply_markup=reply_markup) else: await callback.message.edit_text(text, reply_markup=reply_markup) except TelegramBadRequest as exc: if "message is not modified" not in str(exc).lower(): raise async def update_panel( callback: CallbackQuery, *, context: PanelContext, section: str, ) -> None: config = await get_runtime_config() caption = render_panel_caption( section=section, context=context, brand_name=config.bot_brand_name, ) keyboard = build_panel_keyboard( section=section, context=context, support_url=config.bot_support_url, terms_url=config.bot_terms_url, payment_review_url=config.payment_review_link, support_ticket_url=config.bot_support_ticket_link, ) await edit_message_content(callback, text=caption, reply_markup=keyboard) await callback.answer() async def send_lookup_result(message: Message, *, identifier: str) -> bool: try: user = await sync_service.sync_user_by_identifier( identifier=identifier, include_history=True, ) except RemnawaveApiError as exc: await message.answer( "Пользователь не найден или API вернул ошибку.\n" f"Ошибка: {html.escape(exc.message)}" ) return False await message.answer( format_user_card( user=user.record, squad_names=user.internal_squads, recent_requests=user.recent_requests, ) ) return True async def run_sync_all(message: Message) -> bool: await message.answer("Запустил полную синхронизацию Remnawave -> MariaDB.") try: total = await sync_service.sync_all_users(settings.sync_batch_size) except RemnawaveApiError as exc: await message.answer( "Полная синхронизация завершилась с ошибкой.\n" f"Ошибка: {html.escape(exc.message)}" ) return False await message.answer( f"Полная синхронизация завершена. Обновлено пользователей: {total}" ) return True def admin_settings_callback(action: str, value: str | None = None) -> str: return f"admin:{action}:{value}" if value else f"admin:{action}" def admin_users_page_callback(page: int) -> str: return admin_settings_callback("user_list", str(max(page, 1))) def admin_user_profile_callback(*, telegram_id: int, page: int) -> str: return f"admin:user_view:{telegram_id}:{max(page, 1)}" def compact_inline_label(value: str, *, limit: int = 30) -> str: normalized = " ".join(value.split()) if len(normalized) <= limit: return normalized return f"{normalized[: max(0, limit - 3)].rstrip()}..." def render_admin_settings_home_text(*, config: BotConfigSnapshot) -> str: lines = [ "⚙️ Настройки бота", "", "
Здесь меняются бизнес-настройки бота без правки .env и перезапуска.
", "", "Разделы", ] for section, label in BotConfigService.get_sections(): spec_count = len(BotConfigService.get_specs_for_section(section)) lines.append(f"• {html.escape(label)}: {spec_count}") lines.extend( [ "", "Секреты и инфраструктурные параметры по-прежнему остаются в .env.", ] ) return "\n".join(lines) def build_admin_settings_home_keyboard(): builder = InlineKeyboardBuilder() for section, label in BotConfigService.get_sections(): builder.button( text=f"⚙️ {label}", callback_data=admin_settings_callback("settings_section", section), ) builder.button( text="↻ Обновить", callback_data=admin_settings_callback("settings"), ) builder.button( text="🛠 Админ-панель", callback_data=f"panel:{PANEL_SECTION_ADMIN}", ) builder.adjust(2, 2, 2, 1, 1) return builder.as_markup() def render_admin_settings_section_text( *, config: BotConfigSnapshot, section: str, ) -> str: section_label = BotConfigService.get_section_label(section) specs = BotConfigService.get_specs_for_section(section) lines = [ f"⚙️ {html.escape(section_label)}", "", ] for spec in specs: current_value = BotConfigService.format_value(snapshot=config, key=spec.key) lines.extend( [ f"{html.escape(spec.label)}", f"{html.escape(current_value)}", html.escape(spec.description), "", ] ) lines.append("Нажмите на параметр ниже, чтобы изменить его.") return "\n".join(lines) def build_admin_settings_section_keyboard(*, section: str): builder = InlineKeyboardBuilder() specs = BotConfigService.get_specs_for_section(section) for spec in specs: builder.button( text=f"✏️ {spec.label}", callback_data=admin_settings_callback("edit", spec.key), ) builder.button( text="↻ Обновить", callback_data=admin_settings_callback("settings_section", section), ) builder.button( text="⬅️ Разделы", callback_data=admin_settings_callback("settings"), ) builder.button( text="🛠 Админ-панель", callback_data=f"panel:{PANEL_SECTION_ADMIN}", ) builder.adjust(1) return builder.as_markup() def render_admin_setting_prompt( *, config: BotConfigSnapshot, key: str, ) -> str: spec = BotConfigService.get_spec(key) if spec is None: return "Неизвестная настройка." current_value = BotConfigService.format_value(snapshot=config, key=key) return "\n".join( [ f"{html.escape(spec.label)}", "", html.escape(spec.prompt), "", f"Текущее значение: {html.escape(current_value)}", "", f"{html.escape(spec.description)}", ] ) def render_admin_users_page_text(*, page_data: AdminTelegramUsersPage) -> str: lines = [ "👤 Пользователи", "", ( "
" f"Всего пользователей: {page_data.total_items}\n" f"Страница: {page_data.page}/{page_data.total_pages}" "
" ), ] if not page_data.items: lines.extend(["", "Пользователи ещё не появлялись в боте."]) return "\n".join(lines) for index, item in enumerate( page_data.items, start=((page_data.page - 1) * page_data.page_size) + 1, ): username_text = f"@{item.username}" if item.username else "—" badges: list[str] = [] if item.is_admin: badges.append("админ") if item.is_blocked: badges.append("заблокирован") if not badges: badges.append("пользователь") lines.extend( [ "", ( f"{index}. {html.escape(item.display_name)}\n" "
" f"ID: {item.telegram_id}\n" f"Username: {html.escape(username_text)}\n" f"Статус: {html.escape(', '.join(badges))}\n" f"Последняя активность: {format_datetime(item.last_seen_at)}" "
" ), ] ) return "\n".join(lines) def build_admin_users_page_keyboard(*, page_data: AdminTelegramUsersPage): builder = InlineKeyboardBuilder() for item in page_data.items: button_label = item.display_name if item.username: button_label = f"{button_label} (@{item.username})" else: button_label = f"{button_label} • {item.telegram_id}" builder.row( InlineKeyboardButton( text=f"👤 {compact_inline_label(button_label)}", callback_data=admin_user_profile_callback( telegram_id=item.telegram_id, page=page_data.page, ), ) ) pagination_buttons: list[InlineKeyboardButton] = [] if page_data.page > 1: pagination_buttons.append( InlineKeyboardButton( text="⬅️ Назад", callback_data=admin_users_page_callback(page_data.page - 1), ) ) pagination_buttons.append( InlineKeyboardButton( text=f"{page_data.page}/{page_data.total_pages}", callback_data=admin_users_page_callback(page_data.page), ) ) if page_data.page < page_data.total_pages: pagination_buttons.append( InlineKeyboardButton( text="Вперёд ➡️", callback_data=admin_users_page_callback(page_data.page + 1), ) ) builder.row(*pagination_buttons) builder.row( InlineKeyboardButton( text="↻ Обновить", callback_data=admin_users_page_callback(page_data.page), ), InlineKeyboardButton( text="🛠 Админ-панель", callback_data=f"panel:{PANEL_SECTION_ADMIN}", ), ) return builder.as_markup() def render_admin_user_profile_text(*, profile: AdminTelegramUserProfile) -> str: user = profile.user display_name = " ".join( part for part in [user.first_name, user.last_name] if part ).strip() or user.username or str(user.telegram_id) username_text = f"@{user.username}" if user.username else "—" referral_recent_names = ( ", ".join(profile.recent_invited_names) if profile.recent_invited_names else "—" ) lines = [ f"👤 {html.escape(display_name)}", "", "Telegram-профиль", ( "
" f"ID: {user.telegram_id}\n" f"Username: {html.escape(username_text)}\n" f"Имя: {html.escape(user.first_name or '—')}\n" f"Фамилия: {html.escape(user.last_name or '—')}\n" f"Язык: {html.escape(user.language_code or '—')}\n" f"Админ: {'да' if user.is_admin else 'нет'}\n" f"Заблокирован: {'да' if user.is_blocked else 'нет'}\n" f"Создан: {format_datetime(user.created_at)}\n" f"Последняя активность: {format_datetime(user.last_seen_at)}\n" f"Обновлён: {format_datetime(user.updated_at)}" "
" ), "", "Реферальная система", ( "
" f"Свой код: {html.escape(profile.referral_code or '—')}\n" f"Применённый код: {html.escape(profile.applied_referral_code or '—')}\n" f"Приглашено пользователей: {profile.invited_count}\n" f"Последние приглашённые: {html.escape(referral_recent_names)}" "
" ), "", f"Доступы Remnawave: {len(profile.accesses)}", ] if not profile.accesses: lines.append("
У пользователя пока нет сохранённых доступов.
") return "\n".join(lines) for index, access in enumerate(profile.accesses, start=1): squads = ", ".join(sorted(access.internal_squads)) if access.internal_squads else "—" lines.extend( [ "", f"{index}. {html.escape(access.record.username)} {html.escape(access.record.status)}", ( "
" f"UUID: {html.escape(access.record.rw_uuid)}\n" f"Short UUID: {html.escape(access.record.short_uuid)}\n" f"Истекает: {format_datetime_with_days_left(access.record.expire_at)}\n" f"Трафик: {format_bytes(access.record.used_traffic_bytes)} / " f"{format_traffic_limit(access.record.traffic_limit_bytes)}\n" f"Группы: {html.escape(squads)}\n" f"Email: {html.escape(access.record.email or '—')}\n" f"Tag: {html.escape(access.record.tag or '—')}\n" f"Последнее открытие: {format_datetime(access.record.sub_last_opened_at)}\n" f"Синхронизирован: {format_datetime(access.record.synced_at)}\n" f"Подписка: {html.escape(access.record.subscription_url)}" "
" ), ] ) return "\n".join(lines) def build_admin_user_profile_keyboard(*, profile: AdminTelegramUserProfile, telegram_id: int, page: int): builder = InlineKeyboardBuilder() is_blocked = profile.user.is_blocked if profile else False if is_blocked: builder.row( InlineKeyboardButton( text="\U0001f513 Разблокировать", callback_data=f"admin:unblock:{telegram_id}:{page}", ) ) else: builder.row( InlineKeyboardButton( text="\U0001f512 Заблокировать", callback_data=f"admin:block:{telegram_id}:{page}", ) ) for access in profile.accesses: short = access.record.short_uuid status_upper = access.record.status.upper() if status_upper == "DISABLED": builder.row( InlineKeyboardButton( text=f"\u2705 \u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c {compact_inline_label(access.record.username, limit=16)}", callback_data=f"admin:en_acc:{short}:{telegram_id}:{page}", ) ) else: builder.row( InlineKeyboardButton( text=f"\u26d4 \u041e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c {compact_inline_label(access.record.username, limit=16)}", callback_data=f"admin:dis_acc:{short}:{telegram_id}:{page}", ) ) builder.row( InlineKeyboardButton( text=f"\u23f1 \u0412\u0440\u0435\u043c\u044f {compact_inline_label(access.record.username, limit=16)}", callback_data=f"admin:time_menu:{short}:{telegram_id}:{page}", ) ) builder.row( InlineKeyboardButton( text="\u21bb \u041e\u0431\u043d\u043e\u0432\u0438\u0442\u044c", callback_data=admin_user_profile_callback( telegram_id=telegram_id, page=page, ), ), InlineKeyboardButton( text="\u2b05\ufe0f \u041a \u0441\u043f\u0438\u0441\u043a\u0443", callback_data=admin_users_page_callback(page), ), ) builder.row( InlineKeyboardButton( text="\U0001f6e0 \u0410\u0434\u043c\u0438\u043d-\u043f\u0430\u043d\u0435\u043b\u044c", callback_data=f"panel:{PANEL_SECTION_ADMIN}", ), ) return builder.as_markup() def build_time_adjust_keyboard(*, short_uuid: str, telegram_id: int, page: int): builder = InlineKeyboardBuilder() for days in [1, 5, 10, 15, 30]: builder.button( text=f"+{days} \u0434\u043d.", callback_data=f"admin:at:+{days}:{short_uuid}:{telegram_id}:{page}", ) builder.adjust(5) minus_row: list[InlineKeyboardButton] = [] for days in [1, 5, 10, 15, 30]: minus_row.append( InlineKeyboardButton( text=f"-{days} \u0434\u043d.", callback_data=f"admin:at:-{days}:{short_uuid}:{telegram_id}:{page}", ) ) builder.row(*minus_row) builder.row( InlineKeyboardButton( text="\u2b05\ufe0f \u041d\u0430\u0437\u0430\u0434", callback_data=admin_user_profile_callback( telegram_id=telegram_id, page=page, ), ) ) return builder.as_markup() def render_payment_menu_text( *, plans: list[PaymentPlan], referral_code: str, referral_enabled: bool, referral_discount_percent: int, has_existing_access: bool, ) -> str: lines = [ "💳 Оплата подписки", "", ] if has_existing_access: lines.append( "
У вас уже есть доступ. После подтверждения оплаты бот автоматически продлит текущую подписку на выбранный срок.
" ) lines.append("") if referral_enabled: lines.append( "
Если у вас есть реферальный код, укажите его до оплаты. После выбора тарифа бот покажет реквизиты и попросит отправить чек.
" ) else: lines.append( "
Выберите тариф. После выбора бот покажет реквизиты и попросит отправить чек.
" ) if referral_code: lines.extend( [ "", f"🎟 Активный код: {html.escape(referral_code)}", f"Скидка: {max(referral_discount_percent, 0)}%", ] ) lines.extend(["", "Выберите тариф"]) if not plans: lines.append("
Покупка пока не настроена.
") return "\n".join(lines) for plan in plans: if plan.discount_percent > 0 and plan.original_amount_rub != plan.amount_rub: price_text = ( f"{plan.amount_rub} ₽ " f"{plan.original_amount_rub} ₽" ) else: price_text = f"{plan.amount_rub} ₽" lines.append(f"• {plan.duration_days} дней — {price_text}") return "\n".join(lines) def build_payment_menu_keyboard( *, plans: list[PaymentPlan], referral_code: str, referral_enabled: bool, ): builder = InlineKeyboardBuilder() if referral_enabled: builder.button( text="🎟 Реферальный код" if not referral_code else "🎟 Изменить рефкод", callback_data="referral:apply", ) for plan in plans: price_text = f"{plan.duration_days} дней · {plan.amount_rub} ₽" builder.button( text=price_text, callback_data=payment_callback("select", plan.code), ) builder.button( text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}", ) builder.adjust(1) return builder.as_markup() def build_payment_receipt_keyboard(*, order_uuid: str): builder = InlineKeyboardBuilder() builder.button( text="📎 Отправить чек", callback_data=payment_callback("receipt", order_uuid), ) builder.button(text="⬅️ К тарифам", callback_data=payment_callback("menu")) builder.button(text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}") builder.adjust(1) return builder.as_markup() def build_issued_access_keyboard(*, subscription_url: str): builder = InlineKeyboardBuilder() if subscription_url.startswith(("http://", "https://")): builder.button(text="🔗 Открыть подписку", url=subscription_url) builder.button(text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}") builder.adjust(1) return builder.as_markup() async def send_issued_access_message( bot: Bot, *, chat_id: int, subscription_url: str, expire_at, traffic_limit_bytes: int, is_renewal: bool = False, ) -> None: config = await get_runtime_config() caption = format_issued_access( subscription_url=subscription_url, expire_at=expire_at, traffic_limit_bytes=traffic_limit_bytes, brand_name=config.bot_brand_name, is_renewal=is_renewal, ) reply_markup = build_issued_access_keyboard(subscription_url=subscription_url) start_image_path = get_start_image_path(config) if start_image_path is not None: await bot.send_photo( chat_id=chat_id, photo=FSInputFile(str(start_image_path)), caption=caption, reply_markup=reply_markup, ) return await bot.send_message( chat_id=chat_id, text=caption, reply_markup=reply_markup, ) async def notify_referral_bonus( bot: Bot, *, inviter_telegram_id: int, bonus_days: int, status: str, expire_at=None, total_applied_days: int = 0, ) -> None: lines = [ "🎁 Реферальный бонус начислен", "", f"По вашему коду прошла подтверждённая оплата.", f"Новый бонус: +{bonus_days} дней", ] if status == "APPLIED" and expire_at is not None: if total_applied_days > bonus_days: lines.append(f"Всего применено накопленных бонусов: +{total_applied_days} дней") lines.extend( [ "", f"Ваш доступ продлён до: {format_datetime(expire_at)}", ] ) else: lines.extend( [ "", "Бонус сохранён и применится автоматически, когда у вас появится активный доступ.", ] ) await send_branded( bot, chat_id=inviter_telegram_id, text="\n".join(lines), ) def render_payment_order_text(*, order: CreatedPaymentOrder) -> str: return payment_service.build_transfer_text(order=order) def build_review_keyboard(*, order_uuid: str): builder = InlineKeyboardBuilder() builder.button( text="✅ Подтвердить", callback_data=payment_callback("approve", order_uuid), ) builder.button( text="❌ Отклонить", callback_data=payment_callback("reject", order_uuid), ) builder.adjust(2) return builder.as_markup() def render_review_text( *, order: StoredPaymentOrder, user_message: Message, referral_code: str, ) -> str: username_text = ( f"@{html.escape(user_message.from_user.username)}" if user_message.from_user and user_message.from_user.username else "не указан" ) display_name = " ".join( part for part in [ user_message.from_user.first_name if user_message.from_user else None, user_message.from_user.last_name if user_message.from_user else None, ] if part ).strip() or (user_message.from_user.full_name if user_message.from_user else "Пользователь") receipt_note = (user_message.caption or user_message.text or "").strip() lines = [ f"💸 НОВАЯ ОПЛАТА #{order.order_uuid[:8].upper()}", "", f"Тариф: {html.escape(order.plan_title)}", f"Сумма: {order.amount_rub} ₽", f"Пользователь: {html.escape(display_name)}", f"Telegram ID: {order.telegram_id}", f"Username: {username_text}", f"Логин Remnawave: {html.escape(order.provision_username)}", ] if referral_code: lines.append(f"Рефкод: {html.escape(referral_code)}") if receipt_note: lines.extend( [ "", "Комментарий", f"
{html.escape(receipt_note)}
", ] ) lines.extend( [ "", "Проверьте чек и выберите действие кнопками ниже.", ] ) return "\n".join(lines) async def show_payment_menu_message( message: Message, *, telegram_id: int, ) -> None: config = await get_runtime_config() plans = await payment_service.get_available_plans(telegram_id=telegram_id) has_existing_access = bool(await sync_service.get_cached_users_for_telegram(telegram_id)) referral_code = ( (await sync_service.get_referral_summary(telegram_id)).applied_referral_code if config.referral_enabled else "" ) await answer_branded( message, render_payment_menu_text( plans=plans, referral_code=referral_code, referral_enabled=config.referral_enabled, referral_discount_percent=config.referral_discount_percent, has_existing_access=has_existing_access, ), reply_markup=build_payment_menu_keyboard( plans=plans, referral_code=referral_code, referral_enabled=config.referral_enabled, ), ) async def show_payment_menu_callback(callback: CallbackQuery) -> None: if callback.from_user is None: await callback.answer() return config = await get_runtime_config() plans = await payment_service.get_available_plans(telegram_id=callback.from_user.id) has_existing_access = bool( await sync_service.get_cached_users_for_telegram(callback.from_user.id) ) referral_code = ( (await sync_service.get_referral_summary(callback.from_user.id)).applied_referral_code if config.referral_enabled else "" ) await edit_message_content( callback, text=render_payment_menu_text( plans=plans, referral_code=referral_code, referral_enabled=config.referral_enabled, referral_discount_percent=config.referral_discount_percent, has_existing_access=has_existing_access, ), reply_markup=build_payment_menu_keyboard( plans=plans, referral_code=referral_code, referral_enabled=config.referral_enabled, ), ) await callback.answer() async def send_ticket_to_support(message: Message, *, state: FSMContext) -> None: if message.from_user is None: await state.clear() return config = await get_runtime_config() support_chat_id = config.support_ticket_chat_id if support_chat_id is None: await message.answer( "Канал для тикетов пока не настроен. Проверьте параметры " "BOT_SUPPORT_TICKET_LINK или " "BOT_SUPPORT_TICKET_CHAT_ID." ) await state.clear() return raw_message_text = (message.text or message.caption or "").strip() if not raw_message_text: await message.answer( "Для тикета нужен текст вопроса или проблемы.\n" "Отправьте одно текстовое сообщение или подпись к файлу." ) return ticket_id = secrets.token_hex(4).upper() username_text = ( f"@{html.escape(message.from_user.username)}" if message.from_user.username else "не указан" ) display_name = " ".join( part for part in [message.from_user.first_name, message.from_user.last_name] if part ).strip() or message.from_user.full_name ticket_text = "\n".join( [ f"🎫 НОВЫЙ ТИКЕТ #{ticket_id}", "", f"ID: {ticket_id}", f"Пользователь: {html.escape(display_name)}", f"Telegram ID: {message.from_user.id}", f"Username: {username_text}", f"Создан: {format_datetime(message.date.replace(tzinfo=None))}", "", "Сообщение", f"
{html.escape(raw_message_text)}
", "", "Ответьте реплаем на это сообщение, чтобы бот доставил ответ пользователю.", ] ) support_message = await message.bot.send_message( chat_id=support_chat_id, message_thread_id=config.support_ticket_message_thread_id, text=ticket_text, ) await support_ticket_service.create_ticket( public_id=ticket_id, telegram_id=message.from_user.id, username=message.from_user.username, display_name=display_name, user_message=raw_message_text, support_chat_id=support_chat_id, support_thread_id=config.support_ticket_message_thread_id, support_message_id=support_message.message_id, ) await state.clear() await answer_branded( message, f"🎫 Тикет #{ticket_id} отправлен в поддержку.\n" "Ответ придёт сюда, как только его обработают." ) async def send_receipt_for_review( message: Message, *, order: StoredPaymentOrder, referral_code: str, ) -> None: config = await get_runtime_config() review_chat_id = config.payment_review_chat_id if review_chat_id is None: raise ValueError( "Канал проверки оплат не настроен. Проверьте PAYMENT_REVIEW_LINK или PAYMENT_REVIEW_CHAT_ID." ) review_text = render_review_text( order=order, user_message=message, referral_code=referral_code, ) reply_markup = build_review_keyboard(order_uuid=order.order_uuid) if message.photo: await message.bot.send_photo( chat_id=review_chat_id, message_thread_id=config.payment_review_message_thread_id, photo=message.photo[-1].file_id, caption=review_text, reply_markup=reply_markup, ) return if message.document: await message.bot.send_document( chat_id=review_chat_id, message_thread_id=config.payment_review_message_thread_id, document=message.document.file_id, caption=review_text, reply_markup=reply_markup, ) return receipt_note = (message.text or message.caption or "").strip() if not receipt_note: raise ValueError("Отправьте фото, документ или текст с подтверждением оплаты.") await message.bot.send_message( chat_id=review_chat_id, message_thread_id=config.payment_review_message_thread_id, text=review_text, reply_markup=reply_markup, ) async def append_review_status(callback: CallbackQuery, *, status_text: str) -> None: if callback.message is None: return source_text = callback.message.caption or callback.message.text or "" updated_text = f"{source_text}\n\nСтатус: {status_text}" if callback.message.photo or callback.message.document: await callback.message.edit_caption(caption=updated_text, reply_markup=None) else: await callback.message.edit_text(updated_text, reply_markup=None) @router.message(CommandStart()) @router.message(Command("menu")) async def cmd_start(message: Message, state: FSMContext) -> None: if not await ensure_sender(message): return await clear_interactive_states(state) incoming_referral_code = parse_start_referral_code(message) await sync_service.register_telegram_user( telegram_id=message.from_user.id, username=message.from_user.username, first_name=message.from_user.first_name, last_name=message.from_user.last_name, language_code=message.from_user.language_code, ) context = await load_panel_context( telegram_id=message.from_user.id, username=message.from_user.username, first_name=message.from_user.first_name, last_name=message.from_user.last_name, language_code=message.from_user.language_code, sync_remote=True, ) config = await get_runtime_config() if incoming_referral_code and config.referral_enabled: await message.answer( "👥 Вас пригласили по реферальной ссылке.\n" f"Чтобы получить скидку {max(config.referral_discount_percent, 0)}%, перед оплатой введите код " f"{incoming_referral_code} в разделе " "Реферальная система." ) await show_panel(message, context=context, section=PANEL_SECTION_HOME) @router.message(Command("buy")) async def cmd_buy(message: Message, state: FSMContext) -> None: if not await ensure_sender(message): return await clear_interactive_states(state) await show_payment_menu_message(message, telegram_id=message.from_user.id) @router.message(Command("paysupport")) async def cmd_paysupport(message: Message, state: FSMContext) -> None: if not await ensure_sender(message): return await clear_interactive_states(state) config = await get_runtime_config() support_text = config.payment_support_text.strip() or "По вопросам оплаты напишите в поддержку." await message.answer(html.escape(support_text)) @router.callback_query(F.data == "payment:buy") @router.callback_query(F.data == "payment:menu") async def on_payment_menu(callback: CallbackQuery, state: FSMContext) -> None: await clear_interactive_states(state) await show_payment_menu_callback(callback) @router.callback_query(F.data.startswith("payment:select:")) async def on_payment_select(callback: CallbackQuery, state: FSMContext) -> None: if callback.from_user is None: await callback.answer() return await clear_interactive_states(state) plan_code = (callback.data or "").split(":", maxsplit=2)[2] try: order = await payment_service.create_order( telegram_id=callback.from_user.id, username=callback.from_user.username, first_name=callback.from_user.first_name, last_name=callback.from_user.last_name, language_code=callback.from_user.language_code, plan_code=plan_code, ) except ValueError as exc: await callback.answer(str(exc), show_alert=True) return await edit_message_content( callback, text=render_payment_order_text(order=order), reply_markup=build_payment_receipt_keyboard(order_uuid=order.order_uuid), ) await callback.answer() @router.callback_query(F.data.startswith("payment:receipt:")) async def on_payment_receipt_start(callback: CallbackQuery, state: FSMContext) -> None: if callback.from_user is None or callback.message is None: await callback.answer() return order_uuid = (callback.data or "").split(":", maxsplit=2)[2] try: order = await payment_service.get_order_for_user( order_uuid=order_uuid, telegram_id=callback.from_user.id, ) except ValueError as exc: await callback.answer(str(exc), show_alert=True) return if order.status == REVIEW_PAYMENT_STATUS: await callback.answer("Чек уже отправлен на проверку.", show_alert=True) return if order.status == FULFILLED_PAYMENT_STATUS: await callback.answer("Этот заказ уже подтверждён.", show_alert=True) return if order.status == REJECTED_PAYMENT_STATUS: await callback.answer("Этот заказ уже отклонён. Создайте новый.", show_alert=True) return await state.set_state(ManualPaymentStates.waiting_for_receipt) await state.update_data(order_uuid=order_uuid) await callback.message.answer( "📎 Отправьте фото, скриншот, документ или текст с подтверждением перевода одним следующим сообщением." ) await callback.answer() @router.message(ManualPaymentStates.waiting_for_receipt, Command("cancel")) @router.message(SupportTicketStates.waiting_for_message, Command("cancel")) @router.message(ReferralCodeStates.waiting_for_code, Command("cancel")) @router.message(AdminConfigStates.waiting_for_value, Command("cancel")) async def cancel_interactive_flow(message: Message, state: FSMContext) -> None: await state.clear() await message.answer("Действие отменено.") @router.message(ManualPaymentStates.waiting_for_receipt) async def on_payment_receipt_message(message: Message, state: FSMContext) -> None: if message.from_user is None: await state.clear() return state_data = await state.get_data() order_uuid = str(state_data.get("order_uuid") or "").strip() if not order_uuid: await state.clear() await message.answer("Заказ не найден. Начните оплату заново.") return try: order = await payment_service.get_order_for_user( order_uuid=order_uuid, telegram_id=message.from_user.id, ) except ValueError as exc: await state.clear() await message.answer(html.escape(str(exc))) return if order.status == REVIEW_PAYMENT_STATUS: await state.clear() await message.answer("Чек уже отправлен на проверку.") return if order.status == FULFILLED_PAYMENT_STATUS: await state.clear() await message.answer("Этот заказ уже подтверждён.") return if order.status == REJECTED_PAYMENT_STATUS: await state.clear() await message.answer("Этот заказ уже отклонён. Создайте новый платёж.") return try: order = await payment_service.mark_order_under_review( order_uuid=order_uuid, telegram_id=message.from_user.id, ) except ValueError as exc: await message.answer(html.escape(str(exc))) return try: referral_code = await payment_service.get_referral_code_for_order(order_uuid=order_uuid) await send_receipt_for_review( message, order=order, referral_code=referral_code, ) except (TelegramBadRequest, TelegramForbiddenError, ValueError) as exc: await payment_service.revert_order_to_pending( order_uuid=order_uuid, telegram_id=message.from_user.id, ) await message.answer(html.escape(str(exc))) return await state.clear() await message.answer( "✅ Чек отправлен на проверку.\n" "После подтверждения администратором доступ придёт сюда." ) @router.callback_query(F.data == "referral:apply") async def on_referral_apply(callback: CallbackQuery, state: FSMContext) -> None: if callback.from_user is None or callback.message is None: await callback.answer() return config = await get_runtime_config() if not config.referral_enabled: await callback.answer("Реферальная система сейчас отключена", show_alert=True) return await state.set_state(ReferralCodeStates.waiting_for_code) await callback.message.answer( "🎟 Отправьте реферальный код одним следующим сообщением.\n\n" "Код будет сохранён и применён перед оплатой.\n" "Чтобы отменить ввод, отправьте /cancel." ) await callback.answer() @router.callback_query(F.data == "ticket:create") async def on_ticket_create(callback: CallbackQuery, state: FSMContext) -> None: if callback.from_user is None or callback.message is None: await callback.answer() return config = await get_runtime_config() if config.support_ticket_chat_id is None: await callback.answer("Канал для тикетов не настроен", show_alert=True) return await state.set_state(SupportTicketStates.waiting_for_message) await callback.message.answer( "🎫 Опишите ваш вопрос или проблему одним следующим сообщением.\n\n" "Лучше отправить одно текстовое сообщение, чтобы тикет ушёл в поддержку в чистом виде.\n" "Чтобы отменить создание тикета, отправьте /cancel." ) await callback.answer() @router.callback_query(F.data.startswith("panel:")) async def on_panel_callback(callback: CallbackQuery, state: FSMContext) -> None: if callback.from_user is None: await callback.answer() return if await sync_service.is_user_blocked(callback.from_user.id): await callback.answer("⛔ Ваш аккаунт заблокирован", show_alert=True) return await clear_interactive_states(state) parts = (callback.data or "").split(":") action = parts[1] if len(parts) > 1 else PANEL_SECTION_HOME refresh = len(parts) > 2 and parts[2] == "refresh" valid_sections = { PANEL_SECTION_HOME, PANEL_SECTION_ADMIN, PANEL_SECTION_SUBSCRIPTION, PANEL_SECTION_REFERRAL, PANEL_SECTION_SUPPORT, PANEL_SECTION_SUPPORT_TICKET, PANEL_SECTION_TERMS, } section = action if action in valid_sections else PANEL_SECTION_HOME if section == PANEL_SECTION_ADMIN and not settings.is_admin(callback.from_user.id): await callback.answer("Недостаточно прав", show_alert=True) return config = await get_runtime_config() if section == PANEL_SECTION_REFERRAL and not config.referral_enabled: await callback.answer("Реферальная система сейчас отключена", show_alert=True) section = PANEL_SECTION_HOME context = await load_panel_context( telegram_id=callback.from_user.id, username=callback.from_user.username, first_name=callback.from_user.first_name, last_name=callback.from_user.last_name, language_code=callback.from_user.language_code, sync_remote=refresh, ) await update_panel(callback, context=context, section=section) @router.message(SupportTicketStates.waiting_for_message) async def on_ticket_message(message: Message, state: FSMContext) -> None: await send_ticket_to_support(message, state=state) @router.message(ReferralCodeStates.waiting_for_code) async def on_referral_code_message(message: Message, state: FSMContext) -> None: if message.from_user is None: await state.clear() return config = await get_runtime_config() if not config.referral_enabled: await state.clear() await message.answer("Реферальная система сейчас отключена.") return raw_code = (message.text or "").strip().upper() if not raw_code: await message.answer("Отправьте реферальный код текстом.") return try: applied_code = await sync_service.apply_referral_code( telegram_id=message.from_user.id, username=message.from_user.username, first_name=message.from_user.first_name, last_name=message.from_user.last_name, language_code=message.from_user.language_code, referral_code=raw_code, ) except ValueError as exc: await message.answer(html.escape(str(exc))) return await state.clear() await message.answer( f"✅ Реферальный код {applied_code} сохранён.\n" f"Скидка {max(config.referral_discount_percent, 0)}% будет применена перед оплатой." ) await show_payment_menu_message(message, telegram_id=message.from_user.id) @router.callback_query(F.data == "admin:lookup") async def on_admin_lookup(callback: CallbackQuery, state: FSMContext) -> None: if callback.from_user is None or callback.message is None: await callback.answer() return if not settings.is_admin(callback.from_user.id): await callback.answer("Недостаточно прав", show_alert=True) return await state.set_state(AdminLookupStates.waiting_for_identifier) await callback.message.answer( "🔍 Отправьте идентификатор пользователя следующим сообщением.\n\n" "Поддерживаются: uuid, id, username, short UUID.\n" "Чтобы отменить действие, отправьте /cancel." ) await callback.answer() @router.callback_query(F.data == "admin:sync_all") async def on_admin_sync_all(callback: CallbackQuery, state: FSMContext) -> None: if callback.from_user is None or callback.message is None: await callback.answer() return if not settings.is_admin(callback.from_user.id): await callback.answer("Недостаточно прав", show_alert=True) return await clear_interactive_states(state) await callback.answer("Синхронизация запущена") await run_sync_all(callback.message) context = await load_panel_context( telegram_id=callback.from_user.id, username=callback.from_user.username, first_name=callback.from_user.first_name, last_name=callback.from_user.last_name, language_code=callback.from_user.language_code, sync_remote=False, ) await update_panel(callback, context=context, section=PANEL_SECTION_ADMIN) @router.callback_query(F.data == "admin:settings") async def on_admin_settings(callback: CallbackQuery, state: FSMContext) -> None: if callback.from_user is None: await callback.answer() return if not settings.is_admin(callback.from_user.id): await callback.answer("Недостаточно прав", show_alert=True) return await clear_interactive_states(state) config = await get_runtime_config() await show_branded_view( callback, text=render_admin_settings_home_text(config=config), reply_markup=build_admin_settings_home_keyboard(), ) await callback.answer() @router.callback_query(F.data.startswith("admin:settings_section:")) async def on_admin_settings_section(callback: CallbackQuery, state: FSMContext) -> None: if callback.from_user is None: await callback.answer() return if not settings.is_admin(callback.from_user.id): await callback.answer("Недостаточно прав", show_alert=True) return await clear_interactive_states(state) section = (callback.data or "").split(":", maxsplit=2)[2] if not BotConfigService.get_specs_for_section(section): await callback.answer("Раздел не найден", show_alert=True) return config = await get_runtime_config() await show_branded_view( callback, text=render_admin_settings_section_text(config=config, section=section), reply_markup=build_admin_settings_section_keyboard(section=section), ) await callback.answer() @router.callback_query(F.data == "admin:users") async def on_admin_users(callback: CallbackQuery, state: FSMContext) -> None: if callback.from_user is None: await callback.answer() return if not settings.is_admin(callback.from_user.id): await callback.answer("Недостаточно прав", show_alert=True) return await clear_interactive_states(state) page_data = await sync_service.get_admin_telegram_users_page(page=1) await show_branded_view( callback, text=render_admin_users_page_text(page_data=page_data), reply_markup=build_admin_users_page_keyboard(page_data=page_data), ) await callback.answer() @router.callback_query(F.data.startswith("admin:user_list:")) async def on_admin_users_page(callback: CallbackQuery, state: FSMContext) -> None: if callback.from_user is None: await callback.answer() return if not settings.is_admin(callback.from_user.id): await callback.answer("Недостаточно прав", show_alert=True) return await clear_interactive_states(state) raw_page = (callback.data or "").split(":", maxsplit=2)[2] try: page = int(raw_page) except ValueError: page = 1 page_data = await sync_service.get_admin_telegram_users_page(page=page) await show_branded_view( callback, text=render_admin_users_page_text(page_data=page_data), reply_markup=build_admin_users_page_keyboard(page_data=page_data), ) await callback.answer() @router.callback_query(F.data.startswith("admin:user_view:")) async def on_admin_user_profile(callback: CallbackQuery, state: FSMContext) -> None: if callback.from_user is None: await callback.answer() return if not settings.is_admin(callback.from_user.id): await callback.answer("Недостаточно прав", show_alert=True) return await clear_interactive_states(state) parts = (callback.data or "").split(":") if len(parts) < 4: await callback.answer("Некорректный пользователь", show_alert=True) return try: telegram_id = int(parts[2]) page = int(parts[3]) except ValueError: await callback.answer("Некорректный пользователь", show_alert=True) return profile = await sync_service.get_admin_telegram_user_profile(telegram_id=telegram_id) if profile is None: await callback.answer("Пользователь не найден", show_alert=True) return await show_branded_view( callback, text=render_admin_user_profile_text(profile=profile), reply_markup=build_admin_user_profile_keyboard( profile=profile, telegram_id=telegram_id, page=page, ), ) await callback.answer() # ── Admin: Block / Unblock user ────────────────────────────────── @router.callback_query(F.data.startswith("admin:block:")) async def on_admin_block_user(callback: CallbackQuery) -> None: if callback.from_user is None or callback.message is None: await callback.answer() return if not settings.is_admin(callback.from_user.id): await callback.answer("Недостаточно прав", show_alert=True) return parts = callback.data.split(":") if len(parts) < 4: await callback.answer() return try: target_telegram_id = int(parts[2]) page = int(parts[3]) except ValueError: await callback.answer("Некорректные данные", show_alert=True) return await sync_service.block_telegram_user(target_telegram_id) # Auto-disable all Remnawave accesses try: remote_users = await sync_service._remnawave_client.get_users_by_telegram_id( target_telegram_id ) for ru in remote_users: if ru.status.upper() != "DISABLED": await sync_service._remnawave_client.update_user( {"uuid": str(ru.uuid), "status": "DISABLED"} ) except RemnawaveApiError: pass try: await sync_service.refresh_cached_users_for_telegram( telegram_id=target_telegram_id, ) except RemnawaveApiError: pass profile = await sync_service.get_admin_telegram_user_profile(telegram_id=target_telegram_id) if profile is None: await callback.answer("Пользователь не найден", show_alert=True) return await show_branded_view( callback, text=render_admin_user_profile_text(profile=profile), reply_markup=build_admin_user_profile_keyboard( profile=profile, telegram_id=target_telegram_id, page=page, ), ) await callback.answer("🔒 Пользователь заблокирован", show_alert=True) @router.callback_query(F.data.startswith("admin:unblock:")) async def on_admin_unblock_user(callback: CallbackQuery) -> None: if callback.from_user is None or callback.message is None: await callback.answer() return if not settings.is_admin(callback.from_user.id): await callback.answer("Недостаточно прав", show_alert=True) return parts = callback.data.split(":") if len(parts) < 4: await callback.answer() return try: target_telegram_id = int(parts[2]) page = int(parts[3]) except ValueError: await callback.answer("Некорректные данные", show_alert=True) return await sync_service.unblock_telegram_user(target_telegram_id) # Auto-enable all Remnawave accesses try: remote_users = await sync_service._remnawave_client.get_users_by_telegram_id( target_telegram_id ) for ru in remote_users: if ru.status.upper() == "DISABLED": await sync_service._remnawave_client.update_user( {"uuid": str(ru.uuid), "status": "ACTIVE"} ) except RemnawaveApiError: pass try: await sync_service.refresh_cached_users_for_telegram( telegram_id=target_telegram_id, ) except RemnawaveApiError: pass profile = await sync_service.get_admin_telegram_user_profile(telegram_id=target_telegram_id) if profile is None: await callback.answer("Пользователь не найден", show_alert=True) return await show_branded_view( callback, text=render_admin_user_profile_text(profile=profile), reply_markup=build_admin_user_profile_keyboard( profile=profile, telegram_id=target_telegram_id, page=page, ), ) await callback.answer("🔓 Пользователь разблокирован", show_alert=True) # ── Admin: Disable / Enable individual Remnawave access ────────── @router.callback_query(F.data.startswith("admin:dis_acc:")) async def on_admin_disable_access(callback: CallbackQuery) -> None: if callback.from_user is None or callback.message is None: await callback.answer() return if not settings.is_admin(callback.from_user.id): await callback.answer("Недостаточно прав", show_alert=True) return parts = callback.data.split(":") if len(parts) < 5: await callback.answer() return short_uuid = parts[2] try: target_telegram_id = int(parts[3]) page = int(parts[4]) except ValueError: await callback.answer("Некорректные данные", show_alert=True) return try: rw_user = await sync_service._remnawave_client.get_user_by_short_uuid(short_uuid) await sync_service._remnawave_client.update_user( {"uuid": str(rw_user.uuid), "status": "DISABLED"} ) except RemnawaveApiError as exc: await callback.answer(f"Ошибка: {exc.message[:100]}", show_alert=True) return try: await sync_service.refresh_cached_users_for_telegram(telegram_id=target_telegram_id) except RemnawaveApiError: pass profile = await sync_service.get_admin_telegram_user_profile(telegram_id=target_telegram_id) if profile is None: await callback.answer("Пользователь не найден", show_alert=True) return await show_branded_view( callback, text=render_admin_user_profile_text(profile=profile), reply_markup=build_admin_user_profile_keyboard( profile=profile, telegram_id=target_telegram_id, page=page, ), ) await callback.answer("⛔ Доступ отключён", show_alert=True) @router.callback_query(F.data.startswith("admin:en_acc:")) async def on_admin_enable_access(callback: CallbackQuery) -> None: if callback.from_user is None or callback.message is None: await callback.answer() return if not settings.is_admin(callback.from_user.id): await callback.answer("Недостаточно прав", show_alert=True) return parts = callback.data.split(":") if len(parts) < 5: await callback.answer() return short_uuid = parts[2] try: target_telegram_id = int(parts[3]) page = int(parts[4]) except ValueError: await callback.answer("Некорректные данные", show_alert=True) return try: rw_user = await sync_service._remnawave_client.get_user_by_short_uuid(short_uuid) await sync_service._remnawave_client.update_user( {"uuid": str(rw_user.uuid), "status": "ACTIVE"} ) except RemnawaveApiError as exc: await callback.answer(f"Ошибка: {exc.message[:100]}", show_alert=True) return try: await sync_service.refresh_cached_users_for_telegram(telegram_id=target_telegram_id) except RemnawaveApiError: pass profile = await sync_service.get_admin_telegram_user_profile(telegram_id=target_telegram_id) if profile is None: await callback.answer("Пользователь не найден", show_alert=True) return await show_branded_view( callback, text=render_admin_user_profile_text(profile=profile), reply_markup=build_admin_user_profile_keyboard( profile=profile, telegram_id=target_telegram_id, page=page, ), ) await callback.answer("✅ Доступ включён", show_alert=True) # ── Admin: Time adjustment menu & actions ──────────────────────── @router.callback_query(F.data.startswith("admin:time_menu:")) async def on_admin_time_menu(callback: CallbackQuery) -> None: if callback.from_user is None or callback.message is None: await callback.answer() return if not settings.is_admin(callback.from_user.id): await callback.answer("Недостаточно прав", show_alert=True) return parts = callback.data.split(":") if len(parts) < 5: await callback.answer() return short_uuid = parts[2] try: target_telegram_id = int(parts[3]) page = int(parts[4]) except ValueError: await callback.answer("Некорректные данные", show_alert=True) return try: rw_user = await sync_service._remnawave_client.get_user_by_short_uuid(short_uuid) except RemnawaveApiError as exc: await callback.answer(f"Ошибка: {exc.message[:100]}", show_alert=True) return text = ( f"⏱ Изменение срока подписки\n\n" f"Пользователь: {html.escape(rw_user.username)}\n" f"Текущий срок: {format_datetime_with_days_left(rw_user.expire_at)}\n\n" f"Выберите количество дней для добавления или снятия:" ) await show_branded_view( callback, text=text, reply_markup=build_time_adjust_keyboard( short_uuid=short_uuid, telegram_id=target_telegram_id, page=page, ), ) await callback.answer() @router.callback_query(F.data.startswith("admin:at:")) async def on_admin_adjust_time(callback: CallbackQuery) -> None: if callback.from_user is None or callback.message is None: await callback.answer() return if not settings.is_admin(callback.from_user.id): await callback.answer("Недостаточно прав", show_alert=True) return parts = callback.data.split(":") # admin:at:+5:shortUuid:telegramId:page if len(parts) < 6: await callback.answer() return days_str = parts[2] short_uuid = parts[3] try: target_telegram_id = int(parts[4]) page = int(parts[5]) days = int(days_str) except ValueError: await callback.answer("Некорректные данные", show_alert=True) return from datetime import timedelta, timezone try: rw_user = await sync_service._remnawave_client.get_user_by_short_uuid(short_uuid) except RemnawaveApiError as exc: await callback.answer(f"Ошибка: {exc.message[:100]}", show_alert=True) return current_expire = rw_user.expire_at if current_expire.tzinfo is None: from datetime import timezone as tz current_expire = current_expire.replace(tzinfo=tz.utc) from datetime import datetime as dt now = dt.now(timezone.utc) if days > 0: new_expire = max(current_expire, now) + timedelta(days=days) else: new_expire = current_expire + timedelta(days=days) if new_expire < now: new_expire = now try: await sync_service._remnawave_client.update_user( { "uuid": str(rw_user.uuid), "expireAt": new_expire.isoformat().replace("+00:00", "Z"), } ) except RemnawaveApiError as exc: await callback.answer(f"Ошибка: {exc.message[:100]}", show_alert=True) return try: await sync_service.refresh_cached_users_for_telegram(telegram_id=target_telegram_id) except RemnawaveApiError: pass sign = "+" if days > 0 else "" await callback.answer(f"✅ {sign}{days} дн. применено", show_alert=True) # Re-fetch and show updated profile profile = await sync_service.get_admin_telegram_user_profile(telegram_id=target_telegram_id) if profile is None: return await show_branded_view( callback, text=render_admin_user_profile_text(profile=profile), reply_markup=build_admin_user_profile_keyboard( profile=profile, telegram_id=target_telegram_id, page=page, ), ) @router.callback_query(F.data.startswith("admin:edit:")) async def on_admin_edit_setting(callback: CallbackQuery, state: FSMContext) -> None: if callback.from_user is None or callback.message is None: await callback.answer() return if not settings.is_admin(callback.from_user.id): await callback.answer("Недостаточно прав", show_alert=True) return if not isinstance(bot_config_service, BotConfigService): await callback.answer("Редактирование сейчас недоступно", show_alert=True) return key = (callback.data or "").split(":", maxsplit=2)[2] spec = BotConfigService.get_spec(key) if spec is None: await callback.answer("Настройка не найдена", show_alert=True) return config = await get_runtime_config() await state.set_state(AdminConfigStates.waiting_for_value) await state.update_data(config_key=key, config_section=spec.section) await callback.message.answer( render_admin_setting_prompt(config=config, key=key), reply_markup=build_admin_settings_section_keyboard(section=spec.section), ) await callback.answer() @router.message(AdminLookupStates.waiting_for_identifier) async def on_admin_lookup_message(message: Message, state: FSMContext) -> None: if not await ensure_sender(message): await state.clear() return if not settings.is_admin(message.from_user.id): await state.clear() await message.answer("Недостаточно прав.") return identifier = (message.text or "").strip() if not identifier: await message.answer("Отправьте идентификатор пользователя текстом.") return found = await send_lookup_result(message, identifier=identifier) if found: await state.clear() @router.message(AdminConfigStates.waiting_for_value) async def on_admin_setting_value(message: Message, state: FSMContext) -> None: if not await ensure_sender(message): await state.clear() return if not settings.is_admin(message.from_user.id): await state.clear() await message.answer("Недостаточно прав.") return if not isinstance(bot_config_service, BotConfigService): await state.clear() await message.answer("Редактирование настроек сейчас недоступно.") return state_data = await state.get_data() key = str(state_data.get("config_key") or "").strip() section = str(state_data.get("config_section") or "").strip() spec = BotConfigService.get_spec(key) if not key or spec is None: await state.clear() await message.answer("Настройка не найдена. Откройте админ-панель заново.") return raw_value = (message.text or message.caption or "").strip() if not raw_value: await message.answer("Отправьте новое значение текстом.") return try: await bot_config_service.update_setting( key=key, raw_value=raw_value, updated_by_telegram_id=message.from_user.id, ) except ValueError as exc: await message.answer(html.escape(str(exc))) return await state.clear() config = await get_runtime_config() current_value = BotConfigService.format_value(snapshot=config, key=key) await message.answer( "\n".join( [ f"✅ Настройка {html.escape(spec.label)} сохранена.", f"Новое значение: {html.escape(current_value)}", ] ), reply_markup=build_admin_settings_section_keyboard(section=section or spec.section), ) await message.answer( render_admin_settings_section_text(config=config, section=section or spec.section), reply_markup=build_admin_settings_section_keyboard(section=section or spec.section), ) if True: @router.callback_query(F.data.startswith("payment:approve:")) async def on_payment_approve(callback: CallbackQuery) -> None: if callback.from_user is None: await callback.answer() return if not ( settings.is_admin(callback.from_user.id) or settings.is_moderator(callback.from_user.id) ): await callback.answer("Недостаточно прав", show_alert=True) return order_uuid = (callback.data or "").split(":", maxsplit=2)[2] try: access = await payment_service.approve_order(order_uuid=order_uuid) except (ValueError, RemnawaveApiError) as exc: await callback.answer(str(exc), show_alert=True) return try: await send_issued_access_message( callback.bot, chat_id=access.telegram_id, subscription_url=access.subscription_url, expire_at=access.expire_at, traffic_limit_bytes=access.traffic_limit_bytes, is_renewal=access.is_renewal, ) except TelegramForbiddenError: if access.referral_bonus is not None: try: await notify_referral_bonus( callback.bot, inviter_telegram_id=access.referral_bonus.inviter_telegram_id, bonus_days=access.referral_bonus.bonus_days, status=access.referral_bonus.status, expire_at=access.referral_bonus.expire_at, total_applied_days=access.referral_bonus.total_applied_days, ) except TelegramForbiddenError: pass await append_review_status(callback, status_text="✅ подтверждено, бот у пользователя недоступен") await callback.answer( "Доступ создан, но пользователь заблокировал бота.", show_alert=True, ) return if access.referral_bonus is not None: try: await notify_referral_bonus( callback.bot, inviter_telegram_id=access.referral_bonus.inviter_telegram_id, bonus_days=access.referral_bonus.bonus_days, status=access.referral_bonus.status, expire_at=access.referral_bonus.expire_at, total_applied_days=access.referral_bonus.total_applied_days, ) except TelegramForbiddenError: pass await append_review_status(callback, status_text="✅ подтверждено") await callback.answer("Оплата подтверждена") @router.callback_query(F.data.startswith("payment:reject:")) async def on_payment_reject(callback: CallbackQuery) -> None: if callback.from_user is None: await callback.answer() return if not ( settings.is_admin(callback.from_user.id) or settings.is_moderator(callback.from_user.id) ): await callback.answer("Недостаточно прав", show_alert=True) return order_uuid = (callback.data or "").split(":", maxsplit=2)[2] config = await get_runtime_config() try: order = await payment_service.reject_order( order_uuid=order_uuid, reason=config.payment_support_text.strip() or "Платёж отклонён", ) except ValueError as exc: await callback.answer(str(exc), show_alert=True) return try: await send_branded( callback.bot, chat_id=order.telegram_id, text=( "Платёж не подтверждён\n\n" "Проверьте реквизиты и чек. " f"{html.escape(config.payment_support_text.strip() or 'Если это ошибка, обратитесь в поддержку.')}" ), ) except TelegramForbiddenError: await append_review_status(callback, status_text="❌ отклонено, бот у пользователя недоступен") await callback.answer( "Не удалось уведомить пользователя: бот недоступен.", show_alert=True, ) return await append_review_status(callback, status_text="❌ отклонено") await callback.answer("Оплата отклонена") if True: @router.message(F.reply_to_message) async def on_support_reply(message: Message) -> None: if message.reply_to_message is None or message.from_user is None: return if not ( settings.is_admin(message.from_user.id) or settings.is_moderator(message.from_user.id) ): await message.reply("Недостаточно прав для ответа на тикеты.") return config = await get_runtime_config() if config.support_ticket_chat_id is None or message.chat.id != config.support_ticket_chat_id: return reply_text = (message.text or message.caption or "").strip() if not reply_text: return ticket = await support_ticket_service.get_ticket_by_support_message( support_chat_id=message.chat.id, support_message_id=message.reply_to_message.message_id, ) if ticket is None: return try: await send_branded( message.bot, chat_id=ticket.telegram_id, text=( f"🆘 Ответ по тикету #{ticket.public_id}\n\n" f"{html.escape(reply_text)}" ), ) except TelegramForbiddenError: await message.reply( "Не удалось доставить ответ: пользователь заблокировал бота или недоступен." ) return await support_ticket_service.mark_answered(ticket_id=ticket.id) await message.reply(f"Ответ по тикету #{ticket.public_id} отправлен пользователю.") @router.message(Command("help")) async def cmd_help(message: Message) -> None: if not await ensure_sender(message): return await answer_branded( message, build_help_text(is_admin=settings.is_admin(message.from_user.id)) ) @router.message(Command("sync")) async def cmd_sync(message: Message) -> None: if not await ensure_sender(message): return try: users = await sync_service.sync_users_for_telegram( telegram_id=message.from_user.id, username=message.from_user.username, first_name=message.from_user.first_name, last_name=message.from_user.last_name, language_code=message.from_user.language_code, ) except RemnawaveApiError as exc: await message.answer( "Не удалось обновить данные из Remnawave.\n" f"Ошибка: {html.escape(exc.message)}" ) return if not users: await message.answer( "Синхронизация завершена, но связанных аккаунтов не найдено." ) return context = await load_panel_context( telegram_id=message.from_user.id, username=message.from_user.username, first_name=message.from_user.first_name, last_name=message.from_user.last_name, language_code=message.from_user.language_code, sync_remote=False, ) await show_panel(message, context=context, section=PANEL_SECTION_HOME) @router.message(Command("link")) async def cmd_link(message: Message, command: CommandObject) -> None: if not await ensure_sender(message): return short_uuid = (command.args or "").strip() if not short_uuid: await message.answer( "Укажите short_uuid из вашей ссылки подписки.\n" "Пример значения: AbCdEF12" ) return try: await sync_service.link_user_by_short_uuid( short_uuid=short_uuid, telegram_id=message.from_user.id, username=message.from_user.username, first_name=message.from_user.first_name, last_name=message.from_user.last_name, language_code=message.from_user.language_code, ) except ValueError as exc: await message.answer(html.escape(str(exc))) return except RemnawaveApiError as exc: await message.answer( "Не удалось привязать аккаунт.\n" f"Ошибка API: {html.escape(exc.message)}" ) return context = await load_panel_context( telegram_id=message.from_user.id, username=message.from_user.username, first_name=message.from_user.first_name, last_name=message.from_user.last_name, language_code=message.from_user.language_code, sync_remote=False, ) await message.answer("Аккаунт успешно привязан к вашему Telegram.") await show_panel(message, context=context, section=PANEL_SECTION_SUBSCRIPTION) @router.message(Command("lookup")) async def cmd_lookup(message: Message, command: CommandObject) -> None: if not await ensure_sender(message): return if not settings.is_admin(message.from_user.id): await message.answer("Эта команда доступна только администраторам.") return identifier = (command.args or "").strip() if not identifier: await message.answer( "Укажите идентификатор пользователя.\n" "Поддерживаются: uuid, id, username, short_uuid.\n" "Пример: /lookup 9b488e20-2a1f-4d69-b04d-c7b7f435f1a5" ) return await send_lookup_result(message, identifier=identifier) @router.message(Command("sync_all")) async def cmd_sync_all(message: Message) -> None: if not await ensure_sender(message): return if not settings.is_admin(message.from_user.id): await message.answer("Эта команда доступна только администраторам.") return await run_sync_all(message) return router