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"
)