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": "⚙️ Админ-панель",