mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 11:50:27 +00:00
1515 lines
50 KiB
Python
1515 lines
50 KiB
Python
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', '📋 <b>Правила сервиса</b>')}\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", "ℹ️ <b>Инфо</b>")
|
||
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", "🎯 <b>Промогруппы</b>")
|
||
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", "🎯 <b>Промогруппы</b>")
|
||
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} <b>{name}</b> — от {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", "❓ <b>FAQ</b>")
|
||
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", "❓ <b>FAQ</b>")
|
||
title_template = texts.t("FAQ_PAGE_TITLE", "<b>{title}</b>")
|
||
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"<code>{footer}</code>")
|
||
|
||
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",
|
||
"🛡️ <b>Политика конфиденциальности</b>",
|
||
)
|
||
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<code>{footer}</code>"
|
||
|
||
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",
|
||
"📄 <b>Публичная оферта</b>",
|
||
)
|
||
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<code>{footer}</code>"
|
||
|
||
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"
|
||
)
|