diff --git a/backend/bot/app/web/subscription_webapp.py b/backend/bot/app/web/subscription_webapp.py index 0bf1096..fb03cbb 100644 --- a/backend/bot/app/web/subscription_webapp.py +++ b/backend/bot/app/web/subscription_webapp.py @@ -16,6 +16,7 @@ from bot.app.web.webapp import ( routes as _routes, serializers as _serializers, support as _support, + telegram_notifications as _telegram_notifications, ) _MODULES = ( @@ -25,6 +26,7 @@ _MODULES = ( _assets, _auth, _account, + _telegram_notifications, _serializers, _billing, _devices, diff --git a/backend/bot/app/web/webapp/account.py b/backend/bot/app/web/webapp/account.py index cd7cd46..479595a 100644 --- a/backend/bot/app/web/webapp/account.py +++ b/backend/bot/app/web/webapp/account.py @@ -8,6 +8,7 @@ from .auth import ( _sync_merged_panel_identity_for_user, ) from .common import _invalidate_webapp_user_caches +from .telegram_notifications import _probe_telegram_notifications_for_user_id async def account_email_request_route(request: web.Request) -> web.Response: @@ -416,6 +417,8 @@ async def account_telegram_link_route(request: web.Request) -> web.Response: except Exception: logger.exception("Failed to send account Telegram linked notification") + await _probe_telegram_notifications_for_user_id(request, int(final_user_id)) + token = create_webapp_session_token(settings, int(final_user_id)) response_payload: Dict[str, Any] = { "ok": True, diff --git a/backend/bot/app/web/webapp/auth.py b/backend/bot/app/web/webapp/auth.py index 1627e88..f1bfbe5 100644 --- a/backend/bot/app/web/webapp/auth.py +++ b/backend/bot/app/web/webapp/auth.py @@ -1,6 +1,7 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 from .common import _invalidate_webapp_user_caches +from .telegram_notifications import _probe_telegram_notifications_for_user_id def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]: @@ -436,6 +437,9 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response: first_name=linked_user_for_panel.first_name, ) + if final_user_id: + await _probe_telegram_notifications_for_user_id(request, int(final_user_id)) + token = create_webapp_session_token(settings, int(final_user_id)) response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success")) _clear_telegram_oauth_state_cookie(response) @@ -531,6 +535,7 @@ async def auth_token_route(request: web.Request) -> web.Response: return _json_error(500, "auth_failed", "Auth failed") await _invalidate_webapp_user_caches(settings, authenticated_user_id, include_devices=True) + await _probe_telegram_notifications_for_user_id(request, int(authenticated_user_id)) token = create_webapp_session_token(settings, int(authenticated_user_id)) return _build_webapp_auth_response(settings, {"ok": True}, token=token) diff --git a/backend/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py index 8ee4448..93b5a3b 100644 --- a/backend/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -84,6 +84,10 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_post("/api/account/password/request", account_password_request_route) app.router.add_post("/api/account/password/confirm", account_password_confirm_route) app.router.add_post("/api/account/telegram/link", account_telegram_link_route) + app.router.add_post( + "/api/account/telegram/notifications/probe", + account_telegram_notifications_probe_route, + ) app.router.add_post("/api/promo/apply", apply_promo_route) app.router.add_post("/api/trial/activate", activate_trial_route) app.router.add_get("/api/devices", devices_route) diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index 847fb08..391b9ef 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -3,6 +3,12 @@ from ._runtime import * # noqa: F403,F405 from config.subscription_guides_config import subscription_guides_available from config.webapp_themes_config import public_themes_catalog_payload +from bot.services.telegram_notifications import ( + TELEGRAM_NOTIFICATIONS_ENABLED, + normalize_telegram_notification_status, + telegram_notifications_need_prompt, + telegram_notifications_start_link, +) async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]: @@ -72,6 +78,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])} is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids) + telegram_notifications_status = normalize_telegram_notification_status( + getattr(db_user, "telegram_notifications_status", None) + ) + telegram_notifications_link = telegram_notifications_start_link( + request.app.get("bot_username") or "" + ) return { "user": { "id": user_id, @@ -83,6 +95,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A ), "telegram_id": db_user.telegram_id, "telegram_linked": bool(_telegram_id_for_user(db_user)), + "telegram_notifications_status": telegram_notifications_status, + "telegram_notifications_enabled": ( + telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED + ), + "telegram_notifications_need_prompt": telegram_notifications_need_prompt(db_user), + "telegram_notifications_start_link": telegram_notifications_link, "telegram_photo_url": _telegram_avatar_url(avatar), "first_name": db_user.first_name, "language_code": lang, diff --git a/backend/bot/app/web/webapp/telegram_notifications.py b/backend/bot/app/web/webapp/telegram_notifications.py new file mode 100644 index 0000000..f86f595 --- /dev/null +++ b/backend/bot/app/web/webapp/telegram_notifications.py @@ -0,0 +1,70 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + +from bot.services.telegram_notifications import ( + TELEGRAM_NOTIFICATIONS_ENABLED, + probe_telegram_notifications, + telegram_notifications_start_link, +) +from .common import _invalidate_webapp_user_caches + + +async def _probe_telegram_notifications_for_user_id( + request: web.Request, + user_id: int, + *, + force: bool = False, +) -> Dict[str, Any]: + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + try: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or db_user.is_banned: + await session.rollback() + return { + "ok": False, + "status": "access_denied", + "enabled": False, + "start_link": telegram_notifications_start_link( + request.app.get("bot_username") or "" + ), + } + result = await probe_telegram_notifications( + session=session, + bot=request.app["bot"], + settings=settings, + i18n=request.app.get("i18n"), + user=db_user, + bot_username=request.app.get("bot_username") or "", + force=force, + ) + await session.commit() + status = str(result.get("status") or "") + await _invalidate_webapp_user_caches(settings, int(db_user.user_id)) + return { + "ok": bool(result.get("ok")), + "status": status, + "enabled": status == TELEGRAM_NOTIFICATIONS_ENABLED, + "start_link": result.get("start_link"), + } + except Exception: + await session.rollback() + logger.exception("Telegram notification probe failed") + return { + "ok": False, + "status": "unknown", + "enabled": False, + "start_link": telegram_notifications_start_link( + request.app.get("bot_username") or "" + ), + } + + +async def account_telegram_notifications_probe_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + force = True + result = await _probe_telegram_notifications_for_user_id(request, user_id, force=force) + if result.get("status") == "access_denied": + return _json_error(403, "access_denied", "Access denied") + return web.json_response({"ok": True, "telegram_notifications": result}) diff --git a/backend/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py index 0fd870d..9db11a5 100644 --- a/backend/bot/handlers/user/start.py +++ b/backend/bot/handlers/user/start.py @@ -22,6 +22,7 @@ from bot.services.panel_api_service import PanelApiService from bot.services.promo_code_service import PromoCodeService from bot.services.referral_service import ReferralService from bot.services.subscription_service import SubscriptionService +from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED from bot.utils.callback_answer import safe_answer_callback from bot.utils.install_links import ( append_install_share_link_text, @@ -392,11 +393,12 @@ async def ensure_required_channel_subscription( @router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match"))) +@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match"))) @router.message( CommandStart( magic=F.args.regexp( - r"^(?!ref_|promo_|admin_user_|ticket_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$" + r"^(?!ref_|promo_|admin_user_|ticket_|notifications$|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$" ).as_("ad_param_match") ) ) @@ -414,6 +416,7 @@ async def start_command_handler( ad_param_match: Optional[re.Match] = None, admin_user_match: Optional[re.Match] = None, ticket_match: Optional[re.Match] = None, + notifications_match: Optional[re.Match] = None, ): await state.clear() current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) @@ -500,6 +503,7 @@ async def start_command_handler( promo_code_to_apply: Optional[str] = None should_open_referral_from_start = False ad_start_param: Optional[str] = None + notifications_start_requested = bool(notifications_match) if ref_match: raw_ref_value = ref_match.group(1) @@ -522,6 +526,8 @@ async def start_command_handler( elif promo_match: promo_code_to_apply = promo_match.group(1) logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}") + elif notifications_start_requested: + logging.info("User %s started bot from notifications deep-link.", user_id) elif page_ref_match: should_open_referral_from_start = True logging.info(f"User {user_id} started with page_ref deep-link.") @@ -532,18 +538,24 @@ async def start_command_handler( sanitized_username = sanitize_username(user.username) sanitized_first_name = sanitize_display_name(user.first_name) sanitized_last_name = sanitize_display_name(user.last_name) + notification_status_now = datetime.now(timezone.utc) db_user = await user_dal.get_user_by_id(session, user_id) is_existing_user = db_user is not None if not db_user: user_data_to_create = { "user_id": user_id, + "telegram_id": user_id, "username": sanitized_username, "first_name": sanitized_first_name, "last_name": sanitized_last_name, "language_code": current_lang, "referred_by_id": referred_by_user_id, "registration_date": datetime.now(timezone.utc), + "telegram_notifications_status": TELEGRAM_NOTIFICATIONS_ENABLED, + "telegram_notifications_checked_at": notification_status_now, + "telegram_notifications_enabled_at": notification_status_now, + "telegram_notifications_blocked_at": None, } try: db_user, created = await user_dal.create_user(session, user_data_to_create) @@ -631,6 +643,13 @@ async def start_command_handler( update_payload = {} if db_user.language_code != current_lang: update_payload["language_code"] = current_lang + if db_user.telegram_id != user_id: + update_payload["telegram_id"] = user_id + if db_user.telegram_notifications_status != TELEGRAM_NOTIFICATIONS_ENABLED: + update_payload["telegram_notifications_status"] = TELEGRAM_NOTIFICATIONS_ENABLED + update_payload["telegram_notifications_checked_at"] = notification_status_now + update_payload["telegram_notifications_enabled_at"] = notification_status_now + update_payload["telegram_notifications_blocked_at"] = None # Set referral only if not already set AND user is not currently active. # This allows previously subscribed but currently inactive users to be attributed. if referred_by_user_id and db_user.referred_by_id is None: @@ -684,9 +703,16 @@ async def start_command_handler( open_referral_page_for_existing_user = should_open_referral_from_start and is_existing_user # Send welcome message if not disabled - if not settings.DISABLE_WELCOME_MESSAGE and not open_referral_page_for_existing_user: + if ( + not settings.DISABLE_WELCOME_MESSAGE + and not open_referral_page_for_existing_user + and not notifications_start_requested + ): await message.answer(_(key="welcome", user_name=hd.quote(user.full_name))) + if notifications_start_requested: + await message.answer(_("telegram_notifications_started"), parse_mode="HTML") + # Auto-apply promo code if provided via start parameter if promo_code_to_apply: try: diff --git a/backend/bot/services/subscription_lifecycle_notifications.py b/backend/bot/services/subscription_lifecycle_notifications.py index 7e744b9..8fedd60 100644 --- a/backend/bot/services/subscription_lifecycle_notifications.py +++ b/backend/bot/services/subscription_lifecycle_notifications.py @@ -13,6 +13,14 @@ from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup from bot.middlewares.i18n import JsonI18n from bot.services.email_auth_service import EmailAuthService from bot.services.email_templates import render_subscription_lifecycle_notification +from bot.services.telegram_notifications import ( + TELEGRAM_NOTIFICATIONS_BLOCKED, + TELEGRAM_NOTIFICATIONS_ENABLED, + TELEGRAM_NOTIFICATIONS_NEEDS_START, + mark_telegram_notifications_status, + normalize_telegram_notification_status, + telegram_notification_status_from_error, +) from config.settings import Settings from db.dal import subscription_dal from db.models import Subscription, User @@ -135,32 +143,31 @@ class SubscriptionLifecycleNotificationService: chat_id = self._telegram_chat_id(user, getattr(sub, "user_id", None)) if chat_id is None: return False + if user: + status = normalize_telegram_notification_status( + getattr(user, "telegram_notifications_status", None) + ) + if status in {TELEGRAM_NOTIFICATIONS_NEEDS_START, TELEGRAM_NOTIFICATIONS_BLOCKED}: + return False if await self._already_sent(session, sub.subscription_id, stage.key, "telegram"): return False try: await self.bot.send_message(chat_id, message_text, reply_markup=markup) except (TelegramBadRequest, TelegramForbiddenError) as exc: - if self._is_terminal_telegram_delivery_error(exc): + delivery_status = telegram_notification_status_from_error(exc) + if user and delivery_status: + await mark_telegram_notifications_status( + session, + int(user.user_id), + delivery_status, + ) + if delivery_status: logging.warning( "Skipping subscription notification %s for unreachable Telegram user %s: %s", stage.key, chat_id, exc, ) - try: - await subscription_dal.record_subscription_notification( - session, - sub.subscription_id, - self._channel_key(stage.key, "telegram"), - sent_at=sent_at, - ) - except Exception: - logging.exception( - "Failed to record skipped subscription notification %s " - "for Telegram user %s", - stage.key, - chat_id, - ) return False logging.exception( "Failed to send subscription notification %s to Telegram user %s", @@ -181,6 +188,18 @@ class SubscriptionLifecycleNotificationService: self._channel_key(stage.key, "telegram"), sent_at=sent_at, ) + if user: + status = normalize_telegram_notification_status( + getattr(user, "telegram_notifications_status", None) + ) + if status != TELEGRAM_NOTIFICATIONS_ENABLED: + await mark_telegram_notifications_status( + session, + int(user.user_id), + TELEGRAM_NOTIFICATIONS_ENABLED, + telegram_id=chat_id, + checked_at=sent_at, + ) return True async def _send_email( @@ -333,23 +352,6 @@ class SubscriptionLifecycleNotificationService: return chat_id return None - @staticmethod - def _is_terminal_telegram_delivery_error( - exc: TelegramBadRequest | TelegramForbiddenError, - ) -> bool: - if isinstance(exc, TelegramForbiddenError): - return True - message = str(exc).lower() - return any( - token in message - for token in ( - "chat not found", - "bot was blocked", - "bot can't initiate conversation", - "user is deactivated", - ) - ) - @staticmethod def _as_utc(value: Optional[datetime]) -> Optional[datetime]: if value is None: diff --git a/backend/bot/services/subscription_notification_worker.py b/backend/bot/services/subscription_notification_worker.py index 2ed511b..5cde438 100644 --- a/backend/bot/services/subscription_notification_worker.py +++ b/backend/bot/services/subscription_notification_worker.py @@ -19,6 +19,14 @@ from bot.services.subscription_lifecycle_notifications import ( SubscriptionNotificationStage, ) from bot.services.subscription_service import SubscriptionService +from bot.services.telegram_notifications import ( + TELEGRAM_NOTIFICATIONS_BLOCKED, + TELEGRAM_NOTIFICATIONS_ENABLED, + TELEGRAM_NOTIFICATIONS_NEEDS_START, + mark_telegram_notifications_status, + normalize_telegram_notification_status, + telegram_notification_status_from_error, +) from bot.services.user_email_notifications import send_user_notification_email from config.settings import Settings from db.advisory_locks import acquire_subscription_background_sync_lock @@ -245,6 +253,7 @@ class SubscriptionNotificationWorker: if limit <= 0 or used < limit: continue delivery = await self._send_trial_traffic_depleted( + session, sub, used=used, limit=limit, @@ -284,6 +293,7 @@ class SubscriptionNotificationWorker: async def _send_trial_traffic_depleted( self, + session: AsyncSession, sub: Subscription, *, used: int, @@ -304,20 +314,39 @@ class SubscriptionNotificationWorker: ) telegram_sent = False email_sent = False - if send_telegram and user_id > 0: + telegram_chat_id = int(getattr(user, "telegram_id", 0) or user_id or 0) + telegram_status = normalize_telegram_notification_status( + getattr(user, "telegram_notifications_status", None) + ) + can_try_telegram = telegram_status not in { + TELEGRAM_NOTIFICATIONS_NEEDS_START, + TELEGRAM_NOTIFICATIONS_BLOCKED, + } + if send_telegram and telegram_chat_id > 0 and can_try_telegram: try: await self.bot.send_message( - user_id, + telegram_chat_id, message_text, reply_markup=get_subscribe_only_markup(lang, self.i18n), parse_mode="HTML", ) telegram_sent = True - except Exception: + except Exception as exc: + status = telegram_notification_status_from_error(exc) + if status and user and user_id: + await mark_telegram_notifications_status(session, user_id, status) logging.exception( "Failed to send trial traffic depleted warning to user %s", - user_id, + telegram_chat_id, ) + else: + if user and telegram_status != TELEGRAM_NOTIFICATIONS_ENABLED and user_id: + await mark_telegram_notifications_status( + session, + user_id, + TELEGRAM_NOTIFICATIONS_ENABLED, + telegram_id=telegram_chat_id, + ) if send_email and user: email_sent = await send_user_notification_email( settings=self.settings, diff --git a/backend/bot/services/telegram_notifications.py b/backend/bot/services/telegram_notifications.py new file mode 100644 index 0000000..8d59b83 --- /dev/null +++ b/backend/bot/services/telegram_notifications.py @@ -0,0 +1,240 @@ +import logging +from datetime import datetime, timezone +from typing import Any, Optional + +from aiogram import Bot +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.middlewares.i18n import JsonI18n +from config.settings import Settings +from db.dal import user_dal +from db.models import User + +logger = logging.getLogger(__name__) + +TELEGRAM_NOTIFICATIONS_UNKNOWN = "unknown" +TELEGRAM_NOTIFICATIONS_ENABLED = "enabled" +TELEGRAM_NOTIFICATIONS_NEEDS_START = "needs_start" +TELEGRAM_NOTIFICATIONS_BLOCKED = "blocked" +TELEGRAM_NOTIFICATION_STATUSES = { + TELEGRAM_NOTIFICATIONS_UNKNOWN, + TELEGRAM_NOTIFICATIONS_ENABLED, + TELEGRAM_NOTIFICATIONS_NEEDS_START, + TELEGRAM_NOTIFICATIONS_BLOCKED, +} + + +def normalize_telegram_notification_status(value: Optional[str]) -> str: + status = str(value or "").strip().lower() + return status if status in TELEGRAM_NOTIFICATION_STATUSES else TELEGRAM_NOTIFICATIONS_UNKNOWN + + +def telegram_notifications_enabled(user: Optional[User]) -> bool: + return ( + bool(getattr(user, "telegram_id", None)) + and normalize_telegram_notification_status( + getattr(user, "telegram_notifications_status", None) + ) + == TELEGRAM_NOTIFICATIONS_ENABLED + ) + + +def telegram_notifications_need_prompt(user: Optional[User]) -> bool: + status = normalize_telegram_notification_status( + getattr(user, "telegram_notifications_status", None) + ) + return bool(getattr(user, "telegram_id", None)) and status in { + TELEGRAM_NOTIFICATIONS_NEEDS_START, + TELEGRAM_NOTIFICATIONS_BLOCKED, + } + + +def telegram_notifications_start_link(bot_username: Optional[str]) -> Optional[str]: + username = str(bot_username or "").strip().lstrip("@") + if not username or username == "your_bot_username": + return None + return f"https://t.me/{username}?start=notifications" + + +def telegram_notification_status_from_error(exc: Exception) -> Optional[str]: + if isinstance(exc, TelegramForbiddenError): + return TELEGRAM_NOTIFICATIONS_BLOCKED + if not isinstance(exc, TelegramBadRequest): + return None + + message = str(exc).lower() + if any( + token in message + for token in ( + "bot was blocked", + "user is deactivated", + "forbidden", + ) + ): + return TELEGRAM_NOTIFICATIONS_BLOCKED + if any( + token in message + for token in ( + "chat not found", + "bot can't initiate conversation", + "bot can't initiate", + "user not found", + ) + ): + return TELEGRAM_NOTIFICATIONS_NEEDS_START + return None + + +async def mark_telegram_notifications_status( + session: AsyncSession, + user_id: int, + status: str, + *, + telegram_id: Optional[int] = None, + checked_at: Optional[datetime] = None, +) -> Optional[User]: + normalized = normalize_telegram_notification_status(status) + now = checked_at or datetime.now(timezone.utc) + update_data: dict[str, Any] = { + "telegram_notifications_status": normalized, + "telegram_notifications_checked_at": now, + } + if telegram_id: + update_data["telegram_id"] = int(telegram_id) + if normalized == TELEGRAM_NOTIFICATIONS_ENABLED: + update_data["telegram_notifications_enabled_at"] = now + update_data["telegram_notifications_blocked_at"] = None + elif normalized == TELEGRAM_NOTIFICATIONS_BLOCKED: + update_data["telegram_notifications_blocked_at"] = now + return await user_dal.update_user(session, user_id, update_data) + + +async def mark_telegram_notifications_enabled_for_telegram_user( + session: AsyncSession, + telegram_id: int, +) -> Optional[User]: + db_user = await user_dal.get_user_by_telegram_id(session, telegram_id) + if not db_user: + db_user = await user_dal.get_user_by_id(session, telegram_id) + if not db_user: + return None + return await mark_telegram_notifications_status( + session, + int(db_user.user_id), + TELEGRAM_NOTIFICATIONS_ENABLED, + telegram_id=telegram_id, + ) + + +def _translate( + i18n: Optional[JsonI18n], + language: str, + key: str, + fallback: str, + **kwargs: Any, +) -> str: + if not i18n: + return fallback.format(**kwargs) if kwargs else fallback + return i18n.gettext(language, key, **kwargs) or fallback + + +def _probe_keyboard( + settings: Settings, + i18n: Optional[JsonI18n], + language: str, +) -> Optional[InlineKeyboardMarkup]: + app_url = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip() + if not app_url: + return None + text = _translate( + i18n, + language, + "telegram_notifications_open_app_button", + "Open app", + ) + return InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=text, web_app=WebAppInfo(url=app_url))]] + ) + + +async def probe_telegram_notifications( + *, + session: AsyncSession, + bot: Bot, + settings: Settings, + i18n: Optional[JsonI18n], + user: User, + bot_username: Optional[str] = None, + force: bool = False, +) -> dict[str, Any]: + telegram_id = getattr(user, "telegram_id", None) + if not telegram_id: + return { + "ok": False, + "status": TELEGRAM_NOTIFICATIONS_UNKNOWN, + "start_link": telegram_notifications_start_link(bot_username), + } + + current_status = normalize_telegram_notification_status( + getattr(user, "telegram_notifications_status", None) + ) + if current_status == TELEGRAM_NOTIFICATIONS_ENABLED and not force: + return { + "ok": True, + "status": TELEGRAM_NOTIFICATIONS_ENABLED, + "start_link": telegram_notifications_start_link(bot_username), + } + + language = str(getattr(user, "language_code", "") or settings.DEFAULT_LANGUAGE) + text = _translate( + i18n, + language, + "telegram_notifications_enabled_message", + "Telegram notifications are enabled.", + ) + try: + await bot.send_message( + int(telegram_id), + text, + reply_markup=_probe_keyboard(settings, i18n, language), + disable_web_page_preview=True, + ) + except Exception as exc: + status = telegram_notification_status_from_error(exc) + if status: + await mark_telegram_notifications_status(session, int(user.user_id), status) + return { + "ok": False, + "status": status, + "start_link": telegram_notifications_start_link(bot_username), + } + logger.warning( + "Telegram notification probe failed for user %s / telegram %s: %s", + user.user_id, + telegram_id, + exc, + ) + await mark_telegram_notifications_status( + session, + int(user.user_id), + TELEGRAM_NOTIFICATIONS_UNKNOWN, + ) + return { + "ok": False, + "status": TELEGRAM_NOTIFICATIONS_UNKNOWN, + "start_link": telegram_notifications_start_link(bot_username), + } + + await mark_telegram_notifications_status( + session, + int(user.user_id), + TELEGRAM_NOTIFICATIONS_ENABLED, + telegram_id=int(telegram_id), + ) + return { + "ok": True, + "status": TELEGRAM_NOTIFICATIONS_ENABLED, + "start_link": telegram_notifications_start_link(bot_username), + } diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index 3b2b7dd..3bad5d9 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -408,6 +408,27 @@ async def merge_users( target.channel_subscription_checked_at = source.channel_subscription_checked_at if not target.channel_subscription_verified_for and source.channel_subscription_verified_for: target.channel_subscription_verified_for = source.channel_subscription_verified_for + source_tg_status = str(getattr(source, "telegram_notifications_status", None) or "unknown") + target_tg_status = str(getattr(target, "telegram_notifications_status", None) or "unknown") + if source_tg_status == "enabled" and target_tg_status != "enabled": + target.telegram_notifications_status = source_tg_status + elif target_tg_status == "unknown" and source_tg_status != "unknown": + target.telegram_notifications_status = source_tg_status + if getattr(source, "telegram_notifications_checked_at", None) and ( + not getattr(target, "telegram_notifications_checked_at", None) + or source.telegram_notifications_checked_at > target.telegram_notifications_checked_at + ): + target.telegram_notifications_checked_at = source.telegram_notifications_checked_at + if getattr(source, "telegram_notifications_enabled_at", None) and ( + not getattr(target, "telegram_notifications_enabled_at", None) + or source.telegram_notifications_enabled_at > target.telegram_notifications_enabled_at + ): + target.telegram_notifications_enabled_at = source.telegram_notifications_enabled_at + if getattr(source, "telegram_notifications_blocked_at", None) and ( + not getattr(target, "telegram_notifications_blocked_at", None) + or source.telegram_notifications_blocked_at > target.telegram_notifications_blocked_at + ): + target.telegram_notifications_blocked_at = source.telegram_notifications_blocked_at if source.lifetime_used_traffic_bytes is not None: target.lifetime_used_traffic_bytes = ( target.lifetime_used_traffic_bytes or 0 diff --git a/backend/db/migrator.py b/backend/db/migrator.py index 9afa401..c09e732 100644 --- a/backend/db/migrator.py +++ b/backend/db/migrator.py @@ -1047,6 +1047,20 @@ def _migration_0031_add_subscription_notifications(connection: Connection) -> No ) +def _migration_0032_add_telegram_notification_status(connection: Connection) -> None: + inspector = inspect(connection) + columns: Set[str] = {col["name"] for col in inspector.get_columns("users")} + additions = { + "telegram_notifications_status": "VARCHAR(32) NOT NULL DEFAULT 'unknown'", + "telegram_notifications_checked_at": "TIMESTAMPTZ", + "telegram_notifications_enabled_at": "TIMESTAMPTZ", + "telegram_notifications_blocked_at": "TIMESTAMPTZ", + } + for column, ddl_type in additions.items(): + if column not in columns: + connection.execute(text(f"ALTER TABLE users ADD COLUMN {column} {ddl_type}")) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -1214,6 +1228,11 @@ MIGRATIONS: List[Migration] = [ description="Track sent subscription notification stages", upgrade=_migration_0031_add_subscription_notifications, ), + Migration( + id="0032_add_telegram_notification_status", + description="Track whether the bot can message Telegram-linked users", + upgrade=_migration_0032_add_telegram_notification_status, + ), ] diff --git a/backend/db/models.py b/backend/db/models.py index 08b17d7..209abde 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -33,6 +33,10 @@ class User(Base): password_set_at = Column(DateTime(timezone=True), nullable=True) telegram_id = Column(BigInteger, nullable=True, unique=True, index=True) telegram_photo_url = Column(Text, nullable=True) + telegram_notifications_status = Column(String(32), nullable=False, default="unknown") + telegram_notifications_checked_at = Column(DateTime(timezone=True), nullable=True) + telegram_notifications_enabled_at = Column(DateTime(timezone=True), nullable=True) + telegram_notifications_blocked_at = Column(DateTime(timezone=True), nullable=True) first_name = Column(String, nullable=True) last_name = Column(String, nullable=True) language_code = Column(String, default="ru") diff --git a/docs-site/public/demo/demo-shell.js b/docs-site/public/demo/demo-shell.js index a6f3a27..5335cb2 100644 --- a/docs-site/public/demo/demo-shell.js +++ b/docs-site/public/demo/demo-shell.js @@ -9,6 +9,7 @@ const stateMocks = new Set([ "no-subscription", "trial", "devices", + "notifications", "auth", ]); const routeMocks = new Set([...stateMocks, "guides", "install"]); diff --git a/docs-site/src/pages/demo.astro b/docs-site/src/pages/demo.astro index e091b11..39a3d5b 100644 --- a/docs-site/src/pages/demo.astro +++ b/docs-site/src/pages/demo.astro @@ -294,6 +294,7 @@ const docsHref = '/getting-started/demo/'; + diff --git a/docs/getting-started/demo.md b/docs/getting-started/demo.md index c58615d..8bfc7a7 100644 --- a/docs/getting-started/demo.md +++ b/docs/getting-started/demo.md @@ -12,6 +12,7 @@ - [Админка: бэкапы](/demo/admin/backups) - [Пробный период](/demo/home?mock=trial) - [Докупка устройств](/demo/devices?mock=devices) +- [Запуск бота для Telegram-уведомлений](/demo/home?mock=notifications) - [Вход и регистрация](/demo/login?mock=auth) ## Как собирается diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a44609b..cfc7d79 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -73,6 +73,7 @@ const ACTIVATION_PENDING_WATCH_INTERVAL_MS = 2000; const ACTIVATION_PENDING_WATCH_MAX_ATTEMPTS = 45; const ACTIVATION_RESUME_CHECK_COOLDOWN_MS = 1500; + const TELEGRAM_NOTIFICATIONS_RESUME_REFRESH_COOLDOWN_MS = 1500; import { activationPaymentFailed, createActivationHandoff, @@ -167,6 +168,9 @@ let activationPendingWatchBusy = false; let activationResumeRefreshBusy = false; let activationResumeLastCheckAt = 0; + let telegramNotificationsBotOpenedAt = 0; + let telegramNotificationsResumeRefreshBusy = false; + let telegramNotificationsResumeLastCheckAt = 0; let promoCode = ""; let promoBusy = false; let promoStatus = ""; @@ -475,7 +479,13 @@ languageOptions.find((option) => option.value === currentLang) || languageOptions[0]; $: userLanguage = languageName(currentLang); $: emailLinkStatus = user?.email ? t("wa_settings_linked") : t("wa_settings_email_not_linked"); - $: hasUnlinkedIdentity = !user?.telegram_linked || !user?.email; + $: telegramNotificationsStatus = String(user?.telegram_notifications_status || "unknown"); + $: telegramNotificationsNeedPrompt = Boolean( + user?.telegram_linked && user?.telegram_notifications_need_prompt + ); + $: telegramNotificationsStartLink = String(user?.telegram_notifications_start_link || ""); + $: hasUnlinkedIdentity = + !user?.telegram_linked || !user?.email || telegramNotificationsNeedPrompt; $: referralBonusDetails = Array.isArray(referral?.bonus_details) ? referral.bonus_details : []; $: referralWelcomeBonusDays = Math.max(0, Number(referral?.welcome_bonus_days || 0)); $: referralOneBonusPerReferee = Boolean(referral?.one_bonus_per_referee); @@ -749,6 +759,34 @@ } } + async function refreshTelegramNotificationsOnResume() { + if ( + mode !== "app" || + !telegramNotificationsNeedPrompt || + !telegramNotificationsBotOpenedAt || + telegramNotificationsResumeRefreshBusy + ) { + return; + } + const now = Date.now(); + if ( + now - telegramNotificationsResumeLastCheckAt < + TELEGRAM_NOTIFICATIONS_RESUME_REFRESH_COOLDOWN_MS + ) { + return; + } + telegramNotificationsResumeLastCheckAt = now; + telegramNotificationsResumeRefreshBusy = true; + try { + await loadData({ fresh: true, preserveView: true }); + if (!telegramNotificationsNeedPrompt) telegramNotificationsBotOpenedAt = 0; + } catch (_error) { + void _error; + } finally { + telegramNotificationsResumeRefreshBusy = false; + } + } + function refreshAppLaunchTarget() { appLaunchTarget = readExternalAppLaunchTarget(); return appLaunchTarget; @@ -771,6 +809,7 @@ const onActivationResume = () => { if (typeof document !== "undefined" && document.visibilityState === "hidden") return; void refreshPendingActivationOnResume(); + void refreshTelegramNotificationsOnResume(); }; const onVisibilityChange = () => { if (document.visibilityState !== "hidden") onActivationResume(); @@ -1139,6 +1178,26 @@ } } + function openTelegramNotificationsBot() { + const link = telegramNotificationsStartLink; + telegramNotificationsBotOpenedAt = Date.now(); + if (!link) { + showToast(t("wa_telegram_notifications_link_unavailable")); + return; + } + const currentTg = tg || telegramSdk.refresh(); + if (currentTg?.openTelegramLink && /^https:\/\/t\.me\//i.test(link)) { + try { + tg = currentTg; + currentTg.openTelegramLink(link); + return; + } catch { + // Fall back to generic external opening below. + } + } + openExternalLink(link); + } + function currentSearchParams() { return new URLSearchParams(window.location.search); } @@ -2207,10 +2266,14 @@ {regularTrafficTopupBarClickable} {regularTrafficTopupUnlocked} {subscription} + {telegramNotificationsNeedPrompt} + {telegramNotificationsStartLink} + {telegramNotificationsStatus} {termUnitLabel} {trafficMode} {trialBusy} {activateTrial} + {openTelegramNotificationsBot} openConnectLink={openInstallOrConnect} {openPaymentModal} {openRegularTopupModal} @@ -2314,12 +2377,16 @@ {profileEmail} {profileTelegramId} {supportUrl} + {telegramNotificationsNeedPrompt} + {telegramNotificationsStartLink} + {telegramNotificationsStatus} {telegramProfileName} {user} {userAgreementUrl} {userLanguage} showLogout={!telegramMiniAppContext} linkTelegramAccount={linkTelegramFromSettings} + {openTelegramNotificationsBot} logout={accountStore.logout} {openAdminPanel} {openExternalLink} diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index eaedaeb..7a9a95c 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -103,6 +103,10 @@ function applyDemoEmailAuthUser() { user_id: DEMO_DATASET.currentUser?.user_id || DEMO_DATASET.currentUser?.id || 910001, telegram_id: null, telegram_linked: false, + telegram_notifications_status: "unknown", + telegram_notifications_enabled: false, + telegram_notifications_need_prompt: false, + telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications", telegram_photo_url: "", avatar_url: "", username: DEMO_DATASET.currentUser?.username || "u3252a8", @@ -181,6 +185,10 @@ function applyDemoTelegramAuthUser(authData = {}) { user_id: adminUser.user_id || adminUser.id || 910001, telegram_id: telegramId, telegram_linked: true, + telegram_notifications_status: "needs_start", + telegram_notifications_enabled: false, + telegram_notifications_need_prompt: true, + telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications", username, first_name: firstName, last_name: lastName, @@ -258,6 +266,10 @@ function applyDemoTelegramLink(authData = {}) { user_id: DEV_MOCK.data.user?.user_id || DEV_MOCK.data.user?.id || 910001, telegram_id: telegramId, telegram_linked: true, + telegram_notifications_status: "needs_start", + telegram_notifications_enabled: false, + telegram_notifications_need_prompt: true, + telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications", username: authData.username || authDemo.telegram_username || @@ -1991,6 +2003,27 @@ export async function mockApi(path, options = {}, context = {}) { applyDemoTelegramLink(body.auth_data || {}); return { ok: true, csrf_token: "local-preview-csrf" }; } + if ( + path === "/account/telegram/notifications/probe" && + String(options.method || "").toUpperCase() === "POST" + ) { + DEV_MOCK.data.user = { + ...(DEV_MOCK.data.user || {}), + telegram_notifications_status: "enabled", + telegram_notifications_enabled: true, + telegram_notifications_need_prompt: false, + telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications", + }; + return { + ok: true, + telegram_notifications: { + ok: true, + status: "enabled", + enabled: true, + start_link: "https://t.me/preview_bot?start=notifications", + }, + }; + } if (path === "/payments" && String(options.method || "").toUpperCase() === "POST") { const body = jsonBody(options); if (isDeviceTopupSaleMode(body.sale_mode)) { diff --git a/frontend/src/lib/webapp/previewMock.js b/frontend/src/lib/webapp/previewMock.js index 2c64327..b0634cb 100644 --- a/frontend/src/lib/webapp/previewMock.js +++ b/frontend/src/lib/webapp/previewMock.js @@ -288,6 +288,10 @@ export const DEV_MOCK = { password_auth_enabled: false, telegram_id: 100200300, telegram_linked: true, + telegram_notifications_status: "enabled", + telegram_notifications_enabled: true, + telegram_notifications_need_prompt: false, + telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications", telegram_photo_url: "", first_name: "Preview", language_code: "ru", @@ -457,6 +461,15 @@ function applyDemoDataset() { ...(DEMO_DATASET.currentUser || {}), id: DEMO_DATASET.currentUser?.id ?? DEMO_DATASET.currentUser?.user_id, language_code: storedLanguage || DEMO_DATASET.currentUser?.language_code || "ru", + telegram_notifications_status: + DEMO_DATASET.currentUser?.telegram_notifications_status || "enabled", + telegram_notifications_enabled: + DEMO_DATASET.currentUser?.telegram_notifications_enabled ?? true, + telegram_notifications_need_prompt: + DEMO_DATASET.currentUser?.telegram_notifications_need_prompt ?? false, + telegram_notifications_start_link: + DEMO_DATASET.currentUser?.telegram_notifications_start_link || + "https://t.me/preview_bot?start=notifications", }, 160 ); @@ -588,6 +601,30 @@ export function applyPreviewMock(kind) { return; } + if (mode === "notifications" || mode === "telegram-notifications" || mode === "needs-bot") { + DEV_MOCK.data.user = { + ...(DEV_MOCK.data.user || {}), + telegram_linked: true, + telegram_notifications_status: "needs_start", + telegram_notifications_enabled: false, + telegram_notifications_need_prompt: true, + telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications", + }; + return; + } + + if (mode === "notifications-blocked") { + DEV_MOCK.data.user = { + ...(DEV_MOCK.data.user || {}), + telegram_linked: true, + telegram_notifications_status: "blocked", + telegram_notifications_enabled: false, + telegram_notifications_need_prompt: true, + telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications", + }; + return; + } + if (mode === "tariffs") { DEV_MOCK.data.settings.traffic_mode = false; if (DEMO_DATASET.plans?.length) { diff --git a/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css index 7d95460..0318d1b 100644 --- a/frontend/src/styles/webapp.css +++ b/frontend/src/styles/webapp.css @@ -1957,6 +1957,78 @@ a { justify-content: center; } +.telegram-notifications-card { + display: grid; + grid-template-columns: 38px minmax(0, 1fr); + gap: 10px; + align-items: center; + width: 100%; + border-color: color-mix(in srgb, #ff4b4b 46%, var(--border)); + background: + linear-gradient(135deg, rgba(255, 75, 75, 0.11), rgba(0, 254, 122, 0.05)), var(--card); +} + +.home-layout > .telegram-notifications-card { + align-self: end; +} + +.telegram-notifications-dot { + top: 9px; + right: 9px; + transform: none; +} + +.telegram-notifications-icon { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border-radius: 10px; + background: color-mix(in srgb, #ff4b4b 16%, var(--surface)); + color: #ff6b6b; +} + +.telegram-notifications-copy { + display: grid; + min-width: 0; + gap: 3px; +} + +.telegram-notifications-copy strong { + color: var(--text); + font-size: 14px; + line-height: 1.2; +} + +.telegram-notifications-copy small { + color: var(--muted); + font-size: 12px; + line-height: 1.35; +} + +.telegram-notifications-actions { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 8px; + align-items: center; +} + +.telegram-notifications-actions .btn { + min-width: 0; + width: 100%; + white-space: nowrap; +} + +.telegram-notifications-actions .btn svg { + flex: 0 0 auto; +} + +.telegram-notifications-primary { + grid-column: 1; + grid-row: 1; +} + .attention-wrap { position: relative; } diff --git a/frontend/src/webapp/TelegramNotificationsBanner.svelte b/frontend/src/webapp/TelegramNotificationsBanner.svelte new file mode 100644 index 0000000..eb97d2b --- /dev/null +++ b/frontend/src/webapp/TelegramNotificationsBanner.svelte @@ -0,0 +1,42 @@ + + + + +
+ +
+
+ {title} + {description} +
+
+ +
+
diff --git a/frontend/src/webapp/screens/HomeScreen.svelte b/frontend/src/webapp/screens/HomeScreen.svelte index a3f79b7..334a595 100644 --- a/frontend/src/webapp/screens/HomeScreen.svelte +++ b/frontend/src/webapp/screens/HomeScreen.svelte @@ -13,6 +13,7 @@ import BrandMark from "$lib/webapp/BrandMark.svelte"; import Button from "$components/ui/button.svelte"; import Card from "$components/ui/card.svelte"; + import TelegramNotificationsBanner from "../TelegramNotificationsBanner.svelte"; import { LinearProgress } from "$components/patterns/webapp/index.js"; import { formatTrafficGb } from "../../lib/webapp/formatters.js"; import { @@ -38,6 +39,9 @@ export let hasActiveTariffSubscription = false; export let hasMultipleTariffs = false; export let subscription = {}; + export let telegramNotificationsNeedPrompt = false; + export let telegramNotificationsStartLink = ""; + export let telegramNotificationsStatus = "unknown"; export let trafficMode = false; export let trialBusy = false; export let termUnitLabel = () => ""; // We need this passed from App or context. Actually, App.svelte doesn't pass it yet. We'll pass it. @@ -110,6 +114,7 @@ export let activateTrial = () => {}; export let openConnectLink = () => {}; export let openPaymentModal = () => {}; + export let openTelegramNotificationsBot = () => {}; export let openRegularTopupModal = () => {}; export let openPremiumTopupModal = () => {}; export let openTariffChangeModal = () => {}; @@ -123,6 +128,15 @@

{brandTitle}

+ {#if telegramNotificationsNeedPrompt} + + {/if} +
{#if subscription.active} diff --git a/frontend/src/webapp/screens/SettingsScreen.svelte b/frontend/src/webapp/screens/SettingsScreen.svelte index 68b70f5..45252d1 100644 --- a/frontend/src/webapp/screens/SettingsScreen.svelte +++ b/frontend/src/webapp/screens/SettingsScreen.svelte @@ -13,6 +13,7 @@ import Card from "$components/ui/card.svelte"; import { AttentionDot } from "$components/ui/index.js"; import { LanguageSelect } from "$components/patterns/webapp/index.js"; + import TelegramNotificationsBanner from "../TelegramNotificationsBanner.svelte"; export let currentLang = "ru"; export let currentLanguageOption = null; @@ -30,6 +31,9 @@ export let profileEmail = ""; export let profileTelegramId = ""; export let supportUrl = ""; + export let telegramNotificationsNeedPrompt = false; + export let telegramNotificationsStartLink = ""; + export let telegramNotificationsStatus = "unknown"; export let telegramProfileName = ""; export let user = {}; export let userAgreementUrl = ""; @@ -37,6 +41,7 @@ export let showLogout = true; export let linkTelegramAccount = () => {}; + export let openTelegramNotificationsBot = () => {}; export let logout = () => {}; export let openAdminPanel = () => {}; export let openExternalLink = () => {}; @@ -67,6 +72,14 @@ {profileTelegramId}
+ {#if telegramNotificationsNeedPrompt} + + {/if} {#if isAdmin}
diff --git a/locales/en.json b/locales/en.json index 80ef00d..a931f8c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -7,6 +7,9 @@ "channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.", "webapp_auth_success": "✅ Login confirmed. Return to the Web App.", "webapp_auth_expired": "The login link has expired. Return to the Web App and try again.", + "telegram_notifications_enabled_message": "✅ Telegram notifications are enabled.", + "telegram_notifications_open_app_button": "Open account", + "telegram_notifications_started": "✅ Notifications are enabled. The bot can now send important messages.", "main_menu_greeting": "Hi, {user_name}! 👋\nOpen \"Personal Account\" to set up the connection, view your subscription, or renew access.", "menu_activate_trial_button": "🆓 Free Trial", "menu_subscribe_inline": "🚀 Purchase", @@ -874,6 +877,12 @@ "wa_settings_telegram_linked_title": "Telegram linked", "wa_settings_link_email_action": "Link email", "wa_settings_email_linked_title": "Email linked", + "wa_telegram_notifications_banner_title": "Enable Telegram notifications", + "wa_telegram_notifications_banner_text": "The bot can send subscription, payment, and support updates only after you allow messages or start the chat.", + "wa_telegram_notifications_blocked_title": "The bot cannot send notifications", + "wa_telegram_notifications_blocked_text": "Open the bot in Telegram and unblock the chat, then return to the app.", + "wa_telegram_notifications_open_bot": "Start bot", + "wa_telegram_notifications_link_unavailable": "Bot link is not available yet", "wa_activate_promo_title": "Activate promo code", "wa_traffic_reset_none": "No reset", "wa_traffic_reset_monthly": "Monthly reset", diff --git a/locales/ru.json b/locales/ru.json index 558a0d0..0073410 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -7,6 +7,9 @@ "channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.", "webapp_auth_success": "✅ Вход подтвержден. Вернитесь в Web App.", "webapp_auth_expired": "Ссылка авторизации устарела. Вернитесь в Web App и попробуйте еще раз.", + "telegram_notifications_enabled_message": "✅ Уведомления в Telegram включены.", + "telegram_notifications_open_app_button": "Открыть кабинет", + "telegram_notifications_started": "✅ Уведомления включены. Теперь бот сможет присылать важные сообщения.", "main_menu_greeting": "Привет, {user_name}! 👋\nПерейдите в \"Личный кабинет\", чтобы настроить подключение, посмотреть подписку или продлить доступ.", "menu_activate_trial_button": "🆓 Пробный период", "menu_subscribe_inline": "🚀 Купить", @@ -874,6 +877,12 @@ "wa_settings_telegram_linked_title": "Телеграм привязан", "wa_settings_link_email_action": "Привязать почту", "wa_settings_email_linked_title": "Почта привязана", + "wa_telegram_notifications_banner_title": "Включите уведомления в Telegram", + "wa_telegram_notifications_banner_text": "Бот пришлет напоминания о подписке, оплатах и ответах поддержки только после разрешения или запуска чата.", + "wa_telegram_notifications_blocked_title": "Бот не может отправлять уведомления", + "wa_telegram_notifications_blocked_text": "Откройте бота в Telegram и разблокируйте чат, затем вернитесь в приложение.", + "wa_telegram_notifications_open_bot": "Запустить бота", + "wa_telegram_notifications_link_unavailable": "Ссылка на бота пока недоступна", "wa_activate_promo_title": "Активировать промокод", "wa_traffic_reset_none": "Без сброса", "wa_traffic_reset_monthly": "Сброс ежемесячно", diff --git a/tests/test_account_linking_panel.py b/tests/test_account_linking_panel.py index 4f91f60..0f3174a 100644 --- a/tests/test_account_linking_panel.py +++ b/tests/test_account_linking_panel.py @@ -438,6 +438,11 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase): "bot.services.notification_service.NotificationService", return_value=notification_service, ), + patch.object( + account_routes, + "_probe_telegram_notifications_for_user_id", + AsyncMock(), + ) as probe_telegram_notifications, ): response = await account_routes.account_telegram_link_route(request) @@ -453,6 +458,7 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase): source_user_id=-100, target_user_id=42, ) + probe_telegram_notifications.assert_awaited_once_with(request, 42) self.assertEqual(panel_calls, ["delete", "update"]) panel_service.delete_user_from_panel.assert_awaited_once_with( "panel-email", diff --git a/tests/test_subscription_lifecycle_notifications.py b/tests/test_subscription_lifecycle_notifications.py index d76fe2f..4b79257 100644 --- a/tests/test_subscription_lifecycle_notifications.py +++ b/tests/test_subscription_lifecycle_notifications.py @@ -108,6 +108,7 @@ def _user(**overrides): def test_send_stage_records_telegram_and_email_channel_keys(monkeypatch): recorded = [] + status_changes = [] async def fake_has(session, subscription_id, notification_key): return notification_key in recorded @@ -115,8 +116,19 @@ def test_send_stage_records_telegram_and_email_channel_keys(monkeypatch): async def fake_record(session, subscription_id, notification_key, *, sent_at=None): recorded.append(notification_key) + async def fake_mark_status(session, user_id, status, *, telegram_id=None, checked_at=None): + status_changes.append( + { + "user_id": user_id, + "status": status, + "telegram_id": telegram_id, + "checked_at": checked_at, + } + ) + monkeypatch.setattr(lifecycle.subscription_dal, "has_subscription_notification", fake_has) monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record) + monkeypatch.setattr(lifecycle, "mark_telegram_notifications_status", fake_mark_status) bot = FakeBot() email_service = FakeEmailService() @@ -154,6 +166,15 @@ def test_send_stage_records_telegram_and_email_channel_keys(monkeypatch): assert email_service.messages[0]["email"] == "user@example.test" assert "Mirrored Telegram notice" in email_service.messages[0]["content"].html assert recorded == ["before_3d:telegram", "before_3d:email"] + assert status_changes == [ + { + "user_id": 123, + "status": lifecycle.TELEGRAM_NOTIFICATIONS_ENABLED, + "telegram_id": 555, + "checked_at": status_changes[0]["checked_at"], + } + ] + assert status_changes[0]["checked_at"] is not None def test_legacy_stage_key_suppresses_only_telegram(monkeypatch): @@ -199,8 +220,9 @@ def test_legacy_stage_key_suppresses_only_telegram(monkeypatch): assert recorded == ["before_3d", "before_3d:email"] -def test_terminal_telegram_failure_is_recorded_to_avoid_retry_spam(monkeypatch): +def test_unstarted_telegram_failure_marks_status_without_recording_delivery(monkeypatch): recorded = [] + status_changes = [] async def fake_has(session, subscription_id, notification_key): return notification_key in recorded @@ -208,8 +230,19 @@ def test_terminal_telegram_failure_is_recorded_to_avoid_retry_spam(monkeypatch): async def fake_record(session, subscription_id, notification_key, *, sent_at=None): recorded.append(notification_key) + async def fake_mark_status(session, user_id, status, *, telegram_id=None, checked_at=None): + status_changes.append( + { + "user_id": user_id, + "status": status, + "telegram_id": telegram_id, + "checked_at": checked_at, + } + ) + monkeypatch.setattr(lifecycle.subscription_dal, "has_subscription_notification", fake_has) monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record) + monkeypatch.setattr(lifecycle, "mark_telegram_notifications_status", fake_mark_status) bot = ChatNotFoundBot() settings = _settings() @@ -241,7 +274,15 @@ def test_terminal_telegram_failure_is_recorded_to_avoid_retry_spam(monkeypatch): assert delivery.telegram_sent is False assert delivery.email_sent is False assert bot.calls[0][0] == 777 - assert recorded == ["before_3d:telegram"] + assert recorded == [] + assert status_changes == [ + { + "user_id": 123, + "status": lifecycle.TELEGRAM_NOTIFICATIONS_NEEDS_START, + "telegram_id": None, + "checked_at": None, + } + ] def test_email_only_user_gets_email_name_direct_copy_and_renewal_login_link(monkeypatch): diff --git a/tests/test_telegram_notifications.py b/tests/test_telegram_notifications.py new file mode 100644 index 0000000..0ca675e --- /dev/null +++ b/tests/test_telegram_notifications.py @@ -0,0 +1,24 @@ +from types import SimpleNamespace + +from bot.services.telegram_notifications import telegram_notifications_need_prompt + + +def _user(status: str): + return SimpleNamespace(telegram_id=123, telegram_notifications_status=status) + + +def test_telegram_notifications_prompt_only_for_explicit_unreachable_statuses(): + assert telegram_notifications_need_prompt(_user("needs_start")) is True + assert telegram_notifications_need_prompt(_user("blocked")) is True + assert telegram_notifications_need_prompt(_user("enabled")) is False + assert telegram_notifications_need_prompt(_user("unknown")) is False + + +def test_telegram_notifications_prompt_requires_linked_telegram(): + user = SimpleNamespace( + email="user@example.test", + telegram_id=None, + telegram_notifications_status="needs_start", + ) + + assert telegram_notifications_need_prompt(user) is False