diff --git a/app/handlers/menu.py b/app/handlers/menu.py index d073f0dd..85d7d72d 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -17,6 +17,10 @@ from app.services.subscription_checkout_service import ( ) from app.utils.photo_message import edit_or_answer_photo from app.services.support_settings_service import SupportSettingsService +from app.utils.promo_offer import ( + build_promo_offer_hint, + build_test_access_hint, +) logger = logging.getLogger(__name__) @@ -300,9 +304,38 @@ async def get_main_menu_text(user, texts, db: AsyncSession): user_name=user.full_name, subscription_status=_get_subscription_status(user, texts) ) - + 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: diff --git a/app/handlers/start.py b/app/handlers/start.py index 92187a1f..2caa4889 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -32,6 +32,10 @@ from app.services.admin_notification_service import AdminNotificationService from app.services.subscription_service import SubscriptionService from app.services.support_settings_service import SupportSettingsService from app.utils.user_utils import generate_unique_referral_code +from app.utils.promo_offer import ( + build_promo_offer_hint, + build_test_access_hint, +) from app.database.crud.user_message import get_random_active_message @@ -1279,6 +1283,35 @@ async def get_main_menu_text(user, texts, db: AsyncSession): 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: diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 38e175cc..380952f9 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -1,6 +1,5 @@ import json import logging -import math from datetime import datetime, timedelta from typing import Dict, List, Any, Tuple, Optional @@ -12,7 +11,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings, PERIOD_PRICES, get_traffic_prices from app.database.crud.discount_offer import ( get_offer_by_id, - get_latest_claimed_offer_for_user, mark_offer_claimed, ) from app.database.crud.promo_offer_template import get_promo_offer_template_by_id @@ -74,6 +72,10 @@ from app.utils.subscription_utils import ( get_happ_cryptolink_redirect_link, convert_subscription_link_to_happ_scheme, ) +from app.utils.promo_offer import ( + build_promo_offer_hint, + get_user_active_promo_discount_percent, +) logger = logging.getLogger(__name__) @@ -118,19 +120,7 @@ def _apply_addon_discount( def _get_promo_offer_discount_percent(user: Optional[User]) -> int: - if not user: - return 0 - - try: - percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0) - except (TypeError, ValueError): - return 0 - - expires_at = getattr(user, "promo_offer_discount_expires_at", None) - if expires_at and expires_at <= datetime.utcnow(): - return 0 - - return max(0, min(100, percent)) + return get_user_active_promo_discount_percent(user) def _apply_promo_offer_discount(user: Optional[User], amount: int) -> Dict[str, int]: @@ -143,114 +133,13 @@ def _apply_promo_offer_discount(user: Optional[User], amount: int) -> Dict[str, return {"discounted": discounted, "discount": discount_value, "percent": percent} -def _format_promo_offer_time_left(seconds_left: int, language: str) -> str: - total_minutes = max(1, math.ceil(seconds_left / 60)) - days, remainder_minutes = divmod(total_minutes, 60 * 24) - hours, minutes = divmod(remainder_minutes, 60) - - language_code = (language or "ru").split("-")[0].lower() - if language_code == "en": - day_label, hour_label, minute_label = "d", "h", "m" - else: - day_label, hour_label, minute_label = "д", "ч", "м" - - parts: List[str] = [] - if days: - parts.append(f"{days}{day_label}") - if hours or days: - parts.append(f"{hours}{hour_label}") - parts.append(f"{minutes}{minute_label}") - return " ".join(parts) - - -async def _get_promo_offer_timer_line( - db: AsyncSession, - db_user: User, - texts, -) -> Optional[str]: - expires_at = getattr(db_user, "promo_offer_discount_expires_at", None) - if not expires_at: - return None - - now = datetime.utcnow() - if expires_at <= now: - return None - - seconds_left = int((expires_at - now).total_seconds()) - if seconds_left <= 0: - return None - - total_seconds: Optional[int] = None - source = getattr(db_user, "promo_offer_discount_source", None) - - try: - offer = await get_latest_claimed_offer_for_user(db, db_user.id, source) - except Exception as lookup_error: # pragma: no cover - defensive logging - logger.debug( - "Failed to resolve latest claimed promo offer for user %s: %s", - db_user.id, - lookup_error, - ) - offer = None - - if offer and getattr(offer, "claimed_at", None): - total_seconds = int((expires_at - offer.claimed_at).total_seconds()) - if total_seconds <= 0: - total_seconds = None - - if total_seconds is None and offer and isinstance(offer.extra_data, dict): - raw_duration = ( - offer.extra_data.get("active_discount_hours") - or offer.extra_data.get("duration_hours") - ) - try: - if raw_duration: - total_seconds = int(float(raw_duration) * 3600) - except (TypeError, ValueError): - total_seconds = None - - if total_seconds is None or total_seconds <= 0: - total_seconds = seconds_left - - ratio = max(0.0, min(1.0, seconds_left / total_seconds)) - bar_length = 10 - filled_segments = int(round(ratio * bar_length)) - filled_segments = max(0, min(bar_length, filled_segments)) - if filled_segments == 0 and seconds_left > 0: - filled_segments = 1 - - bar = f"[{'█' * filled_segments}{'░' * (bar_length - filled_segments)}]" - time_left_text = _format_promo_offer_time_left(seconds_left, getattr(texts, "language", "ru")) - - template = texts.t( - "SUBSCRIPTION_PROMO_DISCOUNT_TIMER", - "⏳ Discount active for {time_left}\n{bar}", - ) - return template.format(bar=bar, time_left=time_left_text) - - async def _get_promo_offer_hint( db: AsyncSession, db_user: User, texts, percent: Optional[int] = None, ) -> Optional[str]: - if percent is None: - percent = _get_promo_offer_discount_percent(db_user) - - if percent <= 0: - return None - - base_hint = texts.t( - "SUBSCRIPTION_PROMO_DISCOUNT_HINT", - "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.", - ).format(percent=percent) - - timer_line = await _get_promo_offer_timer_line(db, db_user, texts) - if timer_line: - return f"{base_hint}\n{timer_line}" - - return base_hint + return await build_promo_offer_hint(db, db_user, texts, percent) def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> Optional[int]: diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 3f3a2540..905fbc8b 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -75,6 +75,8 @@ "LOADING": "⏳ Loading...", "MAIN_MENU": "👤 {user_name}\n\n📱 Subscription: {subscription_status}\n\nChoose an option:\n", "MAIN_MENU_ACTION_PROMPT": "Choose an option:", + "MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Test servers active: {count}", + "MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Access active for {time_left}\n{bar}", "MAIN_MENU_BUTTON": "🏠 Main menu", "MANAGE_DEVICES_BUTTON": "🔧 Manage devices", "DEVICE_UUID_NOT_FOUND": "❌ User UUID not found", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index c3f05e10..c1002dcb 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -202,6 +202,8 @@ "MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n", "MAIN_MENU": "👤 {user_name}\n \n📱 Подписка: {subscription_status}\n\nВыберите действие:\n", "MAIN_MENU_ACTION_PROMPT": "Выберите действие:", + "MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Тестовые сервера активны: {count}", + "MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Доступ действует ещё: {time_left}\n{bar}", "MAIN_MENU_BUTTON": "🏠 Главное меню", "MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами", "DEVICE_UUID_NOT_FOUND": "❌ UUID пользователя не найден", diff --git a/app/utils/promo_offer.py b/app/utils/promo_offer.py new file mode 100644 index 00000000..1d672912 --- /dev/null +++ b/app/utils/promo_offer.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import math +from datetime import datetime +from typing import Optional, Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.crud.discount_offer import get_latest_claimed_offer_for_user +from app.database.models import SubscriptionTemporaryAccess, User + + +def get_user_active_promo_discount_percent(user: Optional[User]) -> int: + if not user: + return 0 + + try: + percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0) + except (TypeError, ValueError): + return 0 + + expires_at = getattr(user, "promo_offer_discount_expires_at", None) + if expires_at and expires_at <= datetime.utcnow(): + return 0 + + return max(0, min(100, percent)) + + +def _format_time_left(seconds_left: int, language: str) -> str: + total_minutes = max(1, math.ceil(seconds_left / 60)) + days, remainder_minutes = divmod(total_minutes, 60 * 24) + hours, minutes = divmod(remainder_minutes, 60) + + language_code = (language or "ru").split("-")[0].lower() + if language_code == "en": + day_label, hour_label, minute_label = "d", "h", "m" + else: + day_label, hour_label, minute_label = "д", "ч", "м" + + parts: list[str] = [] + if days: + parts.append(f"{days}{day_label}") + if hours or days: + parts.append(f"{hours}{hour_label}") + parts.append(f"{minutes}{minute_label}") + return " ".join(parts) + + +def _build_progress_bar(seconds_left: int, total_seconds: int) -> str: + if total_seconds <= 0: + total_seconds = seconds_left or 1 + + ratio = max(0.0, min(1.0, seconds_left / total_seconds)) + bar_length = 10 + filled_segments = int(round(ratio * bar_length)) + filled_segments = max(0, min(bar_length, filled_segments)) + if filled_segments == 0 and seconds_left > 0: + filled_segments = 1 + + return f"[{'█' * filled_segments}{'░' * (bar_length - filled_segments)}]" + + +async def build_promo_offer_timer_line( + db: AsyncSession, + user: User, + texts, +) -> Optional[str]: + expires_at = getattr(user, "promo_offer_discount_expires_at", None) + if not expires_at: + return None + + now = datetime.utcnow() + if expires_at <= now: + return None + + seconds_left = int((expires_at - now).total_seconds()) + if seconds_left <= 0: + return None + + total_seconds: Optional[int] = None + source = getattr(user, "promo_offer_discount_source", None) + + try: + offer = await get_latest_claimed_offer_for_user(db, user.id, source) + except Exception: + offer = None + + if offer and getattr(offer, "claimed_at", None): + total_seconds = int((expires_at - offer.claimed_at).total_seconds()) + if total_seconds <= 0: + total_seconds = None + + if total_seconds is None and offer: + extra_data = getattr(offer, "extra_data", None) + if isinstance(extra_data, dict): + raw_duration = ( + extra_data.get("active_discount_hours") + or extra_data.get("duration_hours") + ) + else: + raw_duration = None + try: + if raw_duration: + total_seconds = int(float(raw_duration) * 3600) + except (TypeError, ValueError): + total_seconds = None + + if total_seconds is None or total_seconds <= 0: + total_seconds = seconds_left + + bar = _build_progress_bar(seconds_left, total_seconds) + time_left_text = _format_time_left(seconds_left, getattr(texts, "language", "ru")) + + template = texts.t( + "SUBSCRIPTION_PROMO_DISCOUNT_TIMER", + "⏳ Discount active for {time_left}\n{bar}", + ) + return template.format(bar=bar, time_left=time_left_text) + + +async def build_promo_offer_hint( + db: AsyncSession, + user: User, + texts, + percent: Optional[int] = None, +) -> Optional[str]: + if percent is None: + percent = get_user_active_promo_discount_percent(user) + + if percent <= 0: + return None + + base_hint = texts.t( + "SUBSCRIPTION_PROMO_DISCOUNT_HINT", + "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.", + ).format(percent=percent) + + timer_line = await build_promo_offer_timer_line(db, user, texts) + if timer_line: + return f"{base_hint}\n{timer_line}" + + return base_hint + + +async def build_test_access_hint( + db: AsyncSession, + user: User, + texts, +) -> Optional[str]: + subscription = getattr(user, "subscription", None) + if not subscription: + return None + + subscription_id = getattr(subscription, "id", None) + if not subscription_id: + return None + + now = datetime.utcnow() + + result = await db.execute( + select(SubscriptionTemporaryAccess) + .options(selectinload(SubscriptionTemporaryAccess.offer)) + .where( + SubscriptionTemporaryAccess.subscription_id == subscription_id, + SubscriptionTemporaryAccess.is_active == True, # noqa: E712 + SubscriptionTemporaryAccess.expires_at > now, + ) + .order_by(SubscriptionTemporaryAccess.expires_at.desc()) + ) + entries: Sequence[SubscriptionTemporaryAccess] = result.scalars().all() + + active_entries = [ + entry for entry in entries if entry.expires_at and entry.expires_at > now + ] + if not active_entries: + return None + + latest_expiry = max(entry.expires_at for entry in active_entries) + seconds_left = int((latest_expiry - now).total_seconds()) + if seconds_left <= 0: + return None + + total_seconds: Optional[int] = None + for entry in active_entries: + offer = entry.offer + claimed_at = getattr(offer, "claimed_at", None) if offer else None + if claimed_at: + total = int((entry.expires_at - claimed_at).total_seconds()) + if total > 0 and (total_seconds is None or total > total_seconds): + total_seconds = total + + if total_seconds is None or total_seconds <= 0: + total_seconds = seconds_left + + bar = _build_progress_bar(seconds_left, total_seconds) + time_left_text = _format_time_left(seconds_left, getattr(texts, "language", "ru")) + + unique_squads = { + entry.squad_uuid for entry in active_entries if getattr(entry, "squad_uuid", None) + } + count = len(unique_squads) or len(active_entries) + + header_template = texts.t( + "MAIN_MENU_TEST_ACCESS_HEADER", + "🧪 Test servers active: {count}", + ) + timer_template = texts.t( + "MAIN_MENU_TEST_ACCESS_TIMER", + "⏳ Access active for {time_left}\n{bar}", + ) + + header = header_template.format(count=count) + timer_line = timer_template.format(time_left=time_left_text, bar=bar) + + return f"{header}\n{timer_line}" diff --git a/locales/en.json b/locales/en.json index b6281985..f4df7a86 100644 --- a/locales/en.json +++ b/locales/en.json @@ -55,6 +55,8 @@ "LOADING": "⏳ Loading...", "MAIN_MENU": "👤 {user_name}\n\n📱 Subscription: {subscription_status}\n\nChoose an option:\n", "MAIN_MENU_ACTION_PROMPT": "Choose an option:", + "MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Test servers active: {count}", + "MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Access active for {time_left}\n{bar}", "MAIN_MENU_BUTTON": "🏠 Main menu", "MANAGE_DEVICES_BUTTON": "🔧 Manage devices", "MENU_BALANCE": "💰 Balance", diff --git a/locales/ru.json b/locales/ru.json index 764bbc9b..b1aa0bc9 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -235,6 +235,8 @@ "MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n", "MAIN_MENU": "👤 {user_name}\n \n📱 Подписка: {subscription_status}\n\nВыберите действие:\n", "MAIN_MENU_ACTION_PROMPT": "Выберите действие:", + "MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Тестовые сервера активны: {count}", + "MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Доступ действует ещё: {time_left}\n{bar}", "MAIN_MENU_BUTTON": "🏠 Главное меню", "MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами", "MENU_ADMIN": "⚙️ Админ-панель",