import html import logging from decimal import Decimal from typing import Dict, List from aiogram import Dispatcher, types, F from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from datetime import datetime from app.config import settings from app.database.crud.user import get_user_by_telegram_id, update_user from app.database.crud.promo_group import ( get_auto_assign_promo_groups, has_auto_assign_promo_groups, ) from app.database.crud.transaction import get_user_total_spent_kopeks from app.keyboards.inline import ( get_main_menu_keyboard, get_main_menu_keyboard_async, get_language_selection_keyboard, get_info_menu_keyboard, ) from app.localization.texts import get_texts, get_rules from app.database.models import PromoGroup, User from app.database.crud.user_message import get_random_active_message from app.services.subscription_checkout_service import ( has_subscription_checkout_draft, should_offer_checkout_resume, ) from app.utils.photo_message import edit_or_answer_photo from app.services.support_settings_service import SupportSettingsService from app.services.main_menu_button_service import MainMenuButtonService from app.services.user_cart_service import user_cart_service from app.utils.promo_offer import ( build_promo_offer_hint, build_test_access_hint, ) from app.services.privacy_policy_service import PrivacyPolicyService from app.services.public_offer_service import PublicOfferService from app.services.faq_service import FaqService from app.utils.timezone import format_local_datetime from app.utils.pricing_utils import format_period_description from app.handlers.subscription.traffic import handle_add_traffic, add_traffic logger = logging.getLogger(__name__) def _format_rubles(amount_kopeks: int) -> str: rubles = Decimal(amount_kopeks) / Decimal(100) if rubles == rubles.to_integral_value(): formatted = f"{rubles:,.0f}" else: formatted = f"{rubles:,.2f}" return f"{formatted.replace(',', ' ')} ₽" def _collect_period_discounts(group: PromoGroup) -> Dict[int, int]: discounts: Dict[int, int] = {} raw_discounts = getattr(group, "period_discounts", None) if isinstance(raw_discounts, dict): for key, value in raw_discounts.items(): try: period = int(key) percent = int(value) except (TypeError, ValueError): continue normalized_percent = max(0, min(100, percent)) if normalized_percent > 0: discounts[period] = normalized_percent if group.is_default and settings.is_base_promo_group_period_discount_enabled(): try: base_discounts = settings.get_base_promo_group_period_discounts() or {} except Exception: base_discounts = {} for key, value in base_discounts.items(): try: period = int(key) percent = int(value) except (TypeError, ValueError): continue if period in discounts: continue normalized_percent = max(0, min(100, percent)) if normalized_percent > 0: discounts[period] = normalized_percent return dict(sorted(discounts.items())) def _build_group_discount_lines(group: PromoGroup, texts, language: str) -> list[str]: lines: list[str] = [] if getattr(group, "server_discount_percent", 0) > 0: lines.append( texts.t("PROMO_GROUP_DISCOUNT_SERVERS", "🌍 Серверы: {percent}%").format( percent=group.server_discount_percent ) ) if getattr(group, "traffic_discount_percent", 0) > 0: lines.append( texts.t("PROMO_GROUP_DISCOUNT_TRAFFIC", "📊 Трафик: {percent}%").format( percent=group.traffic_discount_percent ) ) if getattr(group, "device_discount_percent", 0) > 0: lines.append( texts.t("PROMO_GROUP_DISCOUNT_DEVICES", "📱 Доп. устройства: {percent}%").format( percent=group.device_discount_percent ) ) period_discounts = _collect_period_discounts(group) if period_discounts: lines.append( texts.t( "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER", "⏳ Скидки за длительный период:", ) ) for period_days, percent in period_discounts.items(): lines.append( texts.t( "PROMO_GROUP_PERIOD_DISCOUNT_ITEM", "{period} — {percent}%", ).format( period=format_period_description(period_days, language), percent=percent, ) ) return lines async def show_main_menu( callback: types.CallbackQuery, db_user: User, db: AsyncSession, *, skip_callback_answer: bool = False, ): if db_user is None: # Пользователь не найден, используем язык по умолчанию texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( "USER_NOT_FOUND_ERROR", "Ошибка: пользователь не найден.", ), show_alert=True, ) return texts = get_texts(db_user.language) db_user.last_activity = datetime.utcnow() await db.commit() has_active_subscription = bool(db_user.subscription and db_user.subscription.is_active) subscription_is_active = False if db_user.subscription: subscription_is_active = db_user.subscription.is_active menu_text = await get_main_menu_text(db_user, texts, db) draft_exists = await has_subscription_checkout_draft(db_user.id) show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) # Проверяем наличие сохраненной корзины в Redis try: has_saved_cart = await user_cart_service.has_user_cart(db_user.id) except Exception as e: logger.error(f"Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}") has_saved_cart = False is_admin = settings.is_admin(db_user.telegram_id) is_moderator = (not is_admin) and SupportSettingsService.is_moderator( db_user.telegram_id ) custom_buttons = [] if not settings.is_text_main_menu_mode(): custom_buttons = await MainMenuButtonService.get_buttons_for_user( db, is_admin=is_admin, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, ) keyboard = await get_main_menu_keyboard_async( db=db, user=db_user, language=db_user.language, is_admin=is_admin, is_moderator=is_moderator, has_had_paid_subscription=db_user.has_had_paid_subscription, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=db_user.balance_kopeks, subscription=db_user.subscription, show_resume_checkout=show_resume_checkout, has_saved_cart=has_saved_cart, custom_buttons=custom_buttons, ) await edit_or_answer_photo( callback=callback, caption=menu_text, keyboard=keyboard, parse_mode="HTML", force_text=settings.is_text_main_menu_mode(), ) if not skip_callback_answer: await callback.answer() async def handle_profile_unavailable(callback: types.CallbackQuery) -> None: language = getattr(callback.from_user, "language_code", None) or settings.DEFAULT_LANGUAGE try: texts = get_texts(language) except Exception: texts = get_texts() await callback.answer( texts.t( "MENU_PROFILE_UNAVAILABLE", "❗️ Личный кабинет пока недоступен. Попробуйте позже.", ), show_alert=True, ) async def show_service_rules( callback: types.CallbackQuery, db_user: User, db: AsyncSession ): if db_user is None: # Пользователь не найден, используем язык по умолчанию texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( "USER_NOT_FOUND_ERROR", "Ошибка: пользователь не найден.", ), show_alert=True, ) return from app.database.crud.rules import get_current_rules_content texts = get_texts(db_user.language) rules_text = await get_current_rules_content(db, db_user.language) if not rules_text: rules_text = await get_rules(db_user.language) await callback.message.edit_text( f"{texts.t('RULES_HEADER', '📋 Правила сервиса')}\n\n{rules_text}", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] ]) ) await callback.answer() async def show_info_menu( callback: types.CallbackQuery, db_user: User, db: AsyncSession, ): if db_user is None: # Пользователь не найден, используем язык по умолчанию texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( "USER_NOT_FOUND_ERROR", "Ошибка: пользователь не найден.", ), show_alert=True, ) return texts = get_texts(db_user.language) header = texts.t("MENU_INFO_HEADER", "ℹ️ Инфо") prompt = texts.t("MENU_INFO_PROMPT", "Выберите раздел:") caption = f"{header}\n\n{prompt}" if prompt else header privacy_enabled = await PrivacyPolicyService.is_policy_enabled(db, db_user.language) public_offer_enabled = await PublicOfferService.is_offer_enabled(db, db_user.language) faq_enabled = await FaqService.is_enabled(db, db_user.language) promo_groups_available = await has_auto_assign_promo_groups(db) await edit_or_answer_photo( callback=callback, caption=caption, keyboard=get_info_menu_keyboard( language=db_user.language, show_privacy_policy=privacy_enabled, show_public_offer=public_offer_enabled, show_faq=faq_enabled, show_promo_groups=promo_groups_available, ), parse_mode="HTML", ) await callback.answer() async def show_promo_groups_info( callback: types.CallbackQuery, db_user: User, db: AsyncSession, ): if db_user is None: # Пользователь не найден, используем язык по умолчанию texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( "USER_NOT_FOUND_ERROR", "Ошибка: пользователь не найден.", ), show_alert=True, ) return texts = get_texts(db_user.language) promo_groups = await get_auto_assign_promo_groups(db) keyboard = types.InlineKeyboardMarkup( inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]] ) if not promo_groups: empty_text = texts.t( "PROMO_GROUPS_INFO_EMPTY", "Промогруппы с автовыдачей ещё не настроены.", ) header = texts.t("PROMO_GROUPS_INFO_HEADER", "🎯 Промогруппы") message = f"{header}\n\n{empty_text}" if empty_text else header await callback.message.edit_text( message, reply_markup=keyboard, parse_mode="HTML", ) await callback.answer() return total_spent_kopeks = await get_user_total_spent_kopeks(db, db_user.id) total_spent_text = _format_rubles(total_spent_kopeks) sorted_groups = sorted( promo_groups, key=lambda group: (group.auto_assign_total_spent_kopeks or 0, group.id), ) achieved_groups: List[PromoGroup] = [ group for group in sorted_groups if (group.auto_assign_total_spent_kopeks or 0) > 0 and total_spent_kopeks >= (group.auto_assign_total_spent_kopeks or 0) ] current_group = next( (group for group in sorted_groups if group.id == db_user.promo_group_id), None, ) if not current_group and achieved_groups: current_group = achieved_groups[-1] next_group = next( ( group for group in sorted_groups if (group.auto_assign_total_spent_kopeks or 0) > total_spent_kopeks ), None, ) header = texts.t("PROMO_GROUPS_INFO_HEADER", "🎯 Промогруппы") lines: List[str] = [header, ""] spent_line = texts.t( "PROMO_GROUPS_INFO_TOTAL_SPENT", "💰 Потрачено в боте: {amount}", ).format(amount=total_spent_text) lines.append(spent_line) if current_group: lines.append( texts.t( "PROMO_GROUPS_INFO_CURRENT_LEVEL", "🏆 Текущий уровень: {name}", ).format(name=html.escape(current_group.name)), ) else: lines.append( texts.t( "PROMO_GROUPS_INFO_NO_LEVEL", "🏆 Текущий уровень: пока не получен", ) ) if next_group: remaining_kopeks = (next_group.auto_assign_total_spent_kopeks or 0) - total_spent_kopeks lines.append( texts.t( "PROMO_GROUPS_INFO_NEXT_LEVEL", "📈 До уровня «{name}»: осталось {amount}", ).format( name=html.escape(next_group.name), amount=_format_rubles(max(remaining_kopeks, 0)), ) ) else: lines.append( texts.t( "PROMO_GROUPS_INFO_MAX_LEVEL", "🏆 Вы уже получили максимальный уровень скидок!", ) ) lines.extend(["", texts.t("PROMO_GROUPS_INFO_LEVELS_HEADER", "📋 Уровни с автовыдачей:")]) for group in sorted_groups: threshold = group.auto_assign_total_spent_kopeks or 0 status_icon = "✅" if total_spent_kopeks >= threshold else "🔒" lines.append( texts.t( "PROMO_GROUPS_INFO_LEVEL_LINE", "{status} {name} — от {amount}", ).format( status=status_icon, name=html.escape(group.name), amount=_format_rubles(threshold), ) ) discount_lines = _build_group_discount_lines(group, texts, db_user.language) for discount_line in discount_lines: if discount_line: lines.append(f" {discount_line}") lines.append("") while lines and not lines[-1]: lines.pop() message_text = "\n".join(lines) await callback.message.edit_text( message_text, reply_markup=keyboard, parse_mode="HTML", ) await callback.answer() async def show_faq_pages( callback: types.CallbackQuery, db_user: User, db: AsyncSession, ): if db_user is None: # Пользователь не найден, используем язык по умолчанию texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( "USER_NOT_FOUND_ERROR", "Ошибка: пользователь не найден.", ), show_alert=True, ) return texts = get_texts(db_user.language) pages = await FaqService.get_pages(db, db_user.language) if not pages: await callback.answer( texts.t("FAQ_NOT_AVAILABLE", "FAQ временно недоступен."), show_alert=True, ) return header = texts.t("FAQ_HEADER", "❓ FAQ") prompt = texts.t("FAQ_PAGES_PROMPT", "Выберите вопрос:" ) caption = f"{header}\n\n{prompt}" if prompt else header buttons: list[list[types.InlineKeyboardButton]] = [] for index, page in enumerate(pages, start=1): raw_title = (page.title or "").strip() if not raw_title: raw_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия") if len(raw_title) > 60: raw_title = f"{raw_title[:57]}..." buttons.append([ types.InlineKeyboardButton( text=f"{index}. {raw_title}", callback_data=f"menu_faq_page:{page.id}:1", ) ]) buttons.append([ types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info") ]) await callback.message.edit_text( caption, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons), disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW, ) await callback.answer() async def show_faq_page( callback: types.CallbackQuery, db_user: User, db: AsyncSession, ): if db_user is None: # Пользователь не найден, используем язык по умолчанию texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( "USER_NOT_FOUND_ERROR", "Ошибка: пользователь не найден.", ), show_alert=True, ) return texts = get_texts(db_user.language) raw_data = callback.data or "" parts = raw_data.split(":") page_id = None requested_page = 1 if len(parts) >= 2: try: page_id = int(parts[1]) except ValueError: page_id = None if len(parts) >= 3: try: requested_page = int(parts[2]) except ValueError: requested_page = 1 if not page_id: await callback.answer() return page = await FaqService.get_page(db, page_id, db_user.language) if not page or not page.is_active: await callback.answer( texts.t("FAQ_PAGE_NOT_AVAILABLE", "Эта страница FAQ недоступна."), show_alert=True, ) return content_pages = FaqService.split_content_into_pages(page.content) if not content_pages: await callback.answer( texts.t("FAQ_PAGE_EMPTY", "Текст для этой страницы ещё не добавлен."), show_alert=True, ) return total_pages = len(content_pages) current_page = max(1, min(requested_page, total_pages)) header = texts.t("FAQ_HEADER", "❓ FAQ") title_template = texts.t("FAQ_PAGE_TITLE", "{title}") page_title = (page.title or "").strip() if not page_title: page_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия") title_block = title_template.format(title=html.escape(page_title)) body = content_pages[current_page - 1] footer_template = texts.t( "FAQ_PAGE_FOOTER", "Страница {current} из {total}", ) footer = "" if total_pages > 1 and footer_template: try: footer = footer_template.format(current=current_page, total=total_pages) except Exception: footer = f"{current_page}/{total_pages}" parts_to_join = [header, title_block] if body: parts_to_join.append(body) if footer: parts_to_join.append(f"{footer}") message_text = "\n\n".join(segment for segment in parts_to_join if segment) keyboard_rows: list[list[types.InlineKeyboardButton]] = [] if total_pages > 1: nav_row: list[types.InlineKeyboardButton] = [] if current_page > 1: nav_row.append( types.InlineKeyboardButton( text=texts.t("PAGINATION_PREV", "⬅️"), callback_data=f"menu_faq_page:{page.id}:{current_page - 1}", ) ) nav_row.append( types.InlineKeyboardButton( text=f"{current_page}/{total_pages}", callback_data="noop", ) ) if current_page < total_pages: nav_row.append( types.InlineKeyboardButton( text=texts.t("PAGINATION_NEXT", "➡️"), callback_data=f"menu_faq_page:{page.id}:{current_page + 1}", ) ) keyboard_rows.append(nav_row) keyboard_rows.append([ types.InlineKeyboardButton( text=texts.t("FAQ_BACK_TO_LIST", "⬅️ К списку FAQ"), callback_data="menu_faq", ) ]) keyboard_rows.append([ types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info") ]) await callback.message.edit_text( message_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW, ) await callback.answer() async def show_privacy_policy( callback: types.CallbackQuery, db_user: User, db: AsyncSession, ): if db_user is None: # Пользователь не найден, используем язык по умолчанию texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( "USER_NOT_FOUND_ERROR", "Ошибка: пользователь не найден.", ), show_alert=True, ) return texts = get_texts(db_user.language) raw_page = 1 if callback.data and ":" in callback.data: try: raw_page = int(callback.data.split(":", 1)[1]) except ValueError: raw_page = 1 if raw_page < 1: raw_page = 1 policy = await PrivacyPolicyService.get_active_policy(db, db_user.language) if not policy: await callback.answer( texts.t( "PRIVACY_POLICY_NOT_AVAILABLE", "Политика конфиденциальности временно недоступна.", ), show_alert=True, ) return pages = PrivacyPolicyService.split_content_into_pages(policy.content) if not pages: await callback.answer( texts.t( "PRIVACY_POLICY_EMPTY_ALERT", "Политика конфиденциальности ещё не заполнена.", ), show_alert=True, ) return total_pages = len(pages) current_page = raw_page if raw_page <= total_pages else total_pages header = texts.t( "PRIVACY_POLICY_HEADER", "🛡️ Политика конфиденциальности", ) body = pages[current_page - 1] footer_template = texts.t( "PRIVACY_POLICY_PAGE_INFO", "Страница {current} из {total}", ) footer = "" if total_pages > 1 and footer_template: try: footer = footer_template.format(current=current_page, total=total_pages) except Exception: footer = f"{current_page}/{total_pages}" message_text = header if body: message_text += f"\n\n{body}" if footer: message_text += f"\n\n{footer}" keyboard_rows: list[list[types.InlineKeyboardButton]] = [] if total_pages > 1: nav_row: list[types.InlineKeyboardButton] = [] if current_page > 1: nav_row.append( types.InlineKeyboardButton( text=texts.t("PAGINATION_PREV", "⬅️"), callback_data=f"menu_privacy_policy:{current_page - 1}", ) ) nav_row.append( types.InlineKeyboardButton( text=f"{current_page}/{total_pages}", callback_data="noop", ) ) if current_page < total_pages: nav_row.append( types.InlineKeyboardButton( text=texts.t("PAGINATION_NEXT", "➡️"), callback_data=f"menu_privacy_policy:{current_page + 1}", ) ) keyboard_rows.append(nav_row) keyboard_rows.append( [types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")] ) await callback.message.edit_text( message_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW, ) await callback.answer() async def show_public_offer( callback: types.CallbackQuery, db_user: User, db: AsyncSession, ): if db_user is None: # Пользователь не найден, используем язык по умолчанию texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( "USER_NOT_FOUND_ERROR", "Ошибка: пользователь не найден.", ), show_alert=True, ) return texts = get_texts(db_user.language) raw_page = 1 if callback.data and ":" in callback.data: try: raw_page = int(callback.data.split(":", 1)[1]) except ValueError: raw_page = 1 if raw_page < 1: raw_page = 1 offer = await PublicOfferService.get_active_offer(db, db_user.language) if not offer: await callback.answer( texts.t( "PUBLIC_OFFER_NOT_AVAILABLE", "Публичная оферта временно недоступна.", ), show_alert=True, ) return pages = PublicOfferService.split_content_into_pages(offer.content) if not pages: await callback.answer( texts.t( "PUBLIC_OFFER_EMPTY_ALERT", "Публичная оферта ещё не заполнена.", ), show_alert=True, ) return total_pages = len(pages) current_page = raw_page if raw_page <= total_pages else total_pages header = texts.t( "PUBLIC_OFFER_HEADER", "📄 Публичная оферта", ) body = pages[current_page - 1] footer_template = texts.t( "PUBLIC_OFFER_PAGE_INFO", "Страница {current} из {total}", ) footer = "" if total_pages > 1 and footer_template: try: footer = footer_template.format(current=current_page, total=total_pages) except Exception: footer = f"{current_page}/{total_pages}" message_text = header if body: message_text += f"\n\n{body}" if footer: message_text += f"\n\n{footer}" keyboard_rows: list[list[types.InlineKeyboardButton]] = [] if total_pages > 1: nav_row: list[types.InlineKeyboardButton] = [] if current_page > 1: nav_row.append( types.InlineKeyboardButton( text=texts.t("PAGINATION_PREV", "⬅️"), callback_data=f"menu_public_offer:{current_page - 1}", ) ) nav_row.append( types.InlineKeyboardButton( text=f"{current_page}/{total_pages}", callback_data="noop", ) ) if current_page < total_pages: nav_row.append( types.InlineKeyboardButton( text=texts.t("PAGINATION_NEXT", "➡️"), callback_data=f"menu_public_offer:{current_page + 1}", ) ) keyboard_rows.append(nav_row) keyboard_rows.append( [types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")] ) await callback.message.edit_text( message_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW, ) await callback.answer() async def show_language_menu( callback: types.CallbackQuery, db_user: User, db: AsyncSession, ): if db_user is None: # Пользователь не найден, используем язык по умолчанию texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( "USER_NOT_FOUND_ERROR", "Ошибка: пользователь не найден.", ), show_alert=True, ) return texts = get_texts(db_user.language) if not settings.is_language_selection_enabled(): await callback.answer( texts.t( "LANGUAGE_SELECTION_DISABLED", "⚙️ Выбор языка временно недоступен.", ), show_alert=True, ) return await edit_or_answer_photo( callback=callback, caption=texts.t("LANGUAGE_PROMPT", "🌐 Выберите язык интерфейса:"), keyboard=get_language_selection_keyboard( current_language=db_user.language, include_back=True, language=db_user.language, ), parse_mode="HTML", ) await callback.answer() async def process_language_change( callback: types.CallbackQuery, db_user: User, db: AsyncSession, ): if db_user is None: # Пользователь не найден, используем язык по умолчанию texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( "USER_NOT_FOUND_ERROR", "Ошибка: пользователь не найден.", ), show_alert=True, ) return texts = get_texts(db_user.language) if not settings.is_language_selection_enabled(): await callback.answer( texts.t( "LANGUAGE_SELECTION_DISABLED", "⚙️ Выбор языка временно недоступен.", ), show_alert=True, ) return selected_raw = (callback.data or "").split(":", 1)[-1] normalized_selected = selected_raw.strip().lower() available_map = { lang.strip().lower(): lang.strip() for lang in settings.get_available_languages() if isinstance(lang, str) and lang.strip() } if normalized_selected not in available_map: await callback.answer("❌ Unsupported language", show_alert=True) return resolved_language = available_map[normalized_selected].lower() if db_user.language.lower() == normalized_selected: await show_main_menu( callback, db_user, db, skip_callback_answer=True, ) await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен.")) return updated_user = await update_user(db, db_user, language=resolved_language) texts = get_texts(updated_user.language) await show_main_menu( callback, updated_user, db, skip_callback_answer=True, ) await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен.")) async def handle_back_to_menu( callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession ): if db_user is None: # Пользователь не найден, используем язык по умолчанию texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( "USER_NOT_FOUND_ERROR", "Ошибка: пользователь не найден.", ), show_alert=True, ) return await state.clear() texts = get_texts(db_user.language) has_active_subscription = bool(db_user.subscription and db_user.subscription.is_active) subscription_is_active = False if db_user.subscription: subscription_is_active = db_user.subscription.is_active menu_text = await get_main_menu_text(db_user, texts, db) draft_exists = await has_subscription_checkout_draft(db_user.id) show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) # Проверяем наличие сохраненной корзины в Redis try: has_saved_cart = await user_cart_service.has_user_cart(db_user.id) except Exception as e: logger.error(f"Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}") has_saved_cart = False is_admin = settings.is_admin(db_user.telegram_id) is_moderator = (not is_admin) and SupportSettingsService.is_moderator( db_user.telegram_id ) custom_buttons = [] if not settings.is_text_main_menu_mode(): custom_buttons = await MainMenuButtonService.get_buttons_for_user( db, is_admin=is_admin, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, ) keyboard = await get_main_menu_keyboard_async( db=db, user=db_user, language=db_user.language, is_admin=is_admin, is_moderator=is_moderator, has_had_paid_subscription=db_user.has_had_paid_subscription, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=db_user.balance_kopeks, subscription=db_user.subscription, show_resume_checkout=show_resume_checkout, has_saved_cart=has_saved_cart, custom_buttons=custom_buttons, ) await edit_or_answer_photo( callback=callback, caption=menu_text, keyboard=keyboard, parse_mode="HTML", force_text=settings.is_text_main_menu_mode(), ) await callback.answer() def _get_subscription_status(user: User, texts, is_daily_tariff: bool = False) -> str: subscription = getattr(user, "subscription", None) if not subscription: return texts.t("SUB_STATUS_NONE", "❌ Отсутствует") current_time = datetime.utcnow() actual_status = (subscription.actual_status or "").lower() end_date = getattr(subscription, "end_date", None) end_date_text = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None days_left = 0 if subscription.end_date > current_time: days_left = (subscription.end_date - current_time).days if actual_status == "pending": return texts.t("SUBSCRIPTION_NONE", "❌ Нет активной подписки") if actual_status == "disabled": return texts.t("SUB_STATUS_DISABLED", "⚫ Отключена") if actual_status == "expired": return texts.t( "SUB_STATUS_EXPIRED", "🔴 Истекла\n📅 {end_date}", ).format(end_date=end_date_text or "—") is_trial_subscription = getattr(subscription, "is_trial", False) is_trial_like_status = actual_status == "trial" or ( is_trial_subscription and actual_status in {"active", "trial"} ) if is_trial_like_status: if days_left > 1 and end_date_text: return texts.t( "SUB_STATUS_TRIAL_ACTIVE", "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)", ).format( end_date=end_date_text, days=days_left, ) if days_left == 1: return texts.t( "SUB_STATUS_TRIAL_TOMORROW", "🎁 Тестовая подписка\n⚠️ истекает завтра!", ) return texts.t( "SUB_STATUS_TRIAL_TODAY", "🎁 Тестовая подписка\n⚠️ истекает сегодня!", ) if actual_status == "active": # Для суточных тарифов не показываем предупреждение об истечении if is_daily_tariff: return texts.t("SUB_STATUS_DAILY_ACTIVE", "💎 Активна") if days_left > 7 and end_date_text: return texts.t( "SUB_STATUS_ACTIVE_LONG", "💎 Активна\n📅 до {end_date} ({days} дн.)", ).format( end_date=end_date_text, days=days_left, ) if days_left > 1: return texts.t( "SUB_STATUS_ACTIVE_FEW_DAYS", "💎 Активна\n⚠️ истекает через {days} дн.", ).format(days=days_left) if days_left == 1: return texts.t( "SUB_STATUS_ACTIVE_TOMORROW", "💎 Активна\n⚠️ истекает завтра!", ) return texts.t( "SUB_STATUS_ACTIVE_TODAY", "💎 Активна\n⚠️ истекает сегодня!", ) return texts.t("SUB_STATUS_UNKNOWN", "❓ Неизвестно") def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str: if not random_message: return base_text prompt = action_prompt or "" if prompt and prompt in base_text: parts = base_text.split(prompt, 1) if len(parts) == 2: return f"{parts[0]}\n{random_message}\n\n{prompt}{parts[1]}" return base_text.replace(prompt, f"\n{random_message}\n\n{prompt}", 1) return f"{base_text}\n\n{random_message}" async def get_main_menu_text(user, texts, db: AsyncSession): from app.config import settings # Загружаем информацию о тарифе если включен режим тарифов tariff = None is_daily_tariff = False tariff_info_block = "" subscription = getattr(user, "subscription", None) if settings.is_tariffs_mode() and subscription and subscription.tariff_id: try: from app.database.crud.tariff import get_tariff_by_id tariff = await get_tariff_by_id(db, subscription.tariff_id) if tariff: is_daily_tariff = getattr(tariff, 'is_daily', False) # Формируем краткий блок информации о тарифе для главного меню tariff_info_block = f"\n📦 Тариф: {tariff.name}" except Exception as e: logger.debug(f"Не удалось загрузить тариф для главного меню: {e}") base_text = texts.MAIN_MENU.format( user_name=user.full_name, subscription_status=_get_subscription_status(user, texts, is_daily_tariff) ) # Добавляем информацию о тарифе перед "Выберите действие" if tariff_info_block: action_prompt_text = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:") if action_prompt_text in base_text: base_text = base_text.replace( action_prompt_text, f"{tariff_info_block}\n\n{action_prompt_text}" ) action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:") info_sections: list[str] = [] try: promo_hint = await build_promo_offer_hint(db, user, texts) if promo_hint: info_sections.append(promo_hint.strip()) except Exception as hint_error: logger.debug( "Не удалось построить подсказку промо-предложения для пользователя %s: %s", getattr(user, "id", None), hint_error, ) try: test_access_hint = await build_test_access_hint(db, user, texts) if test_access_hint: info_sections.append(test_access_hint.strip()) except Exception as test_error: logger.debug( "Не удалось построить подсказку тестового доступа для пользователя %s: %s", getattr(user, "id", None), test_error, ) if info_sections: extra_block = "\n\n".join(section for section in info_sections if section) if extra_block: base_text = _insert_random_message(base_text, extra_block, action_prompt) try: random_message = await get_random_active_message(db) if random_message: return _insert_random_message(base_text, random_message, action_prompt) except Exception as e: logger.error(f"Ошибка получения случайного сообщения: {e}") return base_text async def handle_activate_button( callback: types.CallbackQuery, db_user: User, db: AsyncSession ): """ Умная кнопка активации — система сама решает что делать: - Если подписка активна — ничего не делать - Если подписка истекла — продлить с теми же параметрами - Если подписки нет — создать новую с дефолтными параметрами Выбирает максимальный период, который можно оплатить из баланса. """ texts = get_texts(db_user.language) from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription from app.database.crud.server_squad import get_server_ids_by_uuids, get_available_server_squads from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance from app.database.models import TransactionType, PaymentMethod from app.services.subscription_service import SubscriptionService from app.services.subscription_renewal_service import SubscriptionRenewalService subscription = await get_subscription_by_user_id(db, db_user.id) # Если подписка активна — ничего не делаем if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow(): await callback.answer( texts.t("SUBSCRIPTION_ALREADY_ACTIVE", "✅ Подписка уже активна!"), show_alert=True, ) return # Определяем параметры подписки if subscription: # Есть подписка (возможно истекшая) — берём её параметры device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT traffic_limit_gb = subscription.traffic_limit_gb or 0 connected_squads = subscription.connected_squads or [] else: # Нет подписки — дефолтные параметры device_limit = settings.DEFAULT_DEVICE_LIMIT traffic_limit_gb = 0 connected_squads = [] # Если серверы не выбраны — берём бесплатные по умолчанию if not connected_squads: available_servers = await get_available_server_squads(db, promo_group_id=db_user.promo_group_id) connected_squads = [ s.squad_uuid for s in available_servers if s.is_available and s.price_kopeks == 0 ] # Если бесплатных нет — берём первый доступный if not connected_squads and available_servers: connected_squads = [available_servers[0].squad_uuid] server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else [] balance = db_user.balance_kopeks available_periods = sorted(settings.get_available_subscription_periods(), reverse=True) subscription_service = SubscriptionService() # Найти максимальный период <= баланса best_period = None best_price = 0 for period in available_periods: price, _ = await subscription_service.calculate_subscription_price_with_months( period, traffic_limit_gb, server_ids, device_limit, db, user=db_user ) if price <= balance: best_period = period best_price = price break if not best_period: # Показать сколько не хватает для минимального периода min_period = min(available_periods) if available_periods else 30 min_price, _ = await subscription_service.calculate_subscription_price_with_months( min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user ) missing = min_price - balance await callback.answer( texts.t( "INSUFFICIENT_FUNDS_DETAILED", f"❌ Недостаточно средств. Не хватает {missing // 100} ₽" ), show_alert=True, ) return try: if subscription: # Продление существующей подписки renewal_service = SubscriptionRenewalService() pricing = await renewal_service.calculate_pricing( db, db_user, subscription, best_period ) result = await renewal_service.finalize( db, db_user, subscription, pricing, description=f"Автоматическое продление на {best_period} дней", payment_method=PaymentMethod.BALANCE, ) await callback.answer( texts.t( "ACTIVATION_SUCCESS", f"✅ Подписка продлена на {best_period} дней за {best_price // 100} ₽!" ), show_alert=True, ) else: # Создание новой подписки new_subscription = await create_paid_subscription( db, db_user.id, best_period, traffic_limit_gb=traffic_limit_gb, device_limit=device_limit, connected_squads=connected_squads, update_server_counters=True ) # Списать баланс правильно await subtract_user_balance( db, db_user, best_price, f"Активация подписки на {best_period} дней" ) # Создать пользователя в RemnaWave await subscription_service.create_remnawave_user(db, new_subscription) # Создать транзакцию await create_transaction( db=db, user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=best_price, description=f"Активация подписки на {best_period} дней", payment_method=PaymentMethod.BALANCE, ) await callback.answer( texts.t( "ACTIVATION_SUCCESS", f"✅ Подписка активирована на {best_period} дней за {best_price // 100} ₽!" ), show_alert=True, ) except Exception as e: logger.error(f"Ошибка автоматической активации для {db_user.telegram_id}: {e}") await db.rollback() await callback.answer( texts.t("ACTIVATION_ERROR", "❌ Ошибка активации. Попробуйте позже."), show_alert=True, ) def register_handlers(dp: Dispatcher): dp.callback_query.register( handle_back_to_menu, F.data == "back_to_menu" ) dp.callback_query.register( handle_profile_unavailable, F.data == "menu_profile_unavailable", ) dp.callback_query.register( show_service_rules, F.data == "menu_rules" ) dp.callback_query.register( show_info_menu, F.data == "menu_info", ) dp.callback_query.register( show_promo_groups_info, F.data == "menu_info_promo_groups", ) dp.callback_query.register( show_faq_pages, F.data == "menu_faq", ) dp.callback_query.register( show_faq_page, F.data.startswith("menu_faq_page:"), ) dp.callback_query.register( show_privacy_policy, F.data == "menu_privacy_policy", ) dp.callback_query.register( show_privacy_policy, F.data.startswith("menu_privacy_policy:"), ) dp.callback_query.register( show_public_offer, F.data == "menu_public_offer", ) dp.callback_query.register( show_public_offer, F.data.startswith("menu_public_offer:"), ) dp.callback_query.register( show_language_menu, F.data == "menu_language" ) dp.callback_query.register( process_language_change, F.data.startswith("language_select:"), StateFilter(None) ) dp.callback_query.register( handle_add_traffic, F.data == "buy_traffic" ) dp.callback_query.register( add_traffic, F.data.startswith("add_traffic_") ) dp.callback_query.register( handle_activate_button, F.data == "activate_button" )