feat: prompt users to start Telegram bot for notifications

This commit is contained in:
3252a8
2026-05-30 21:15:17 +03:00
parent 8c0e778388
commit acc222da41
28 changed files with 854 additions and 41 deletions
@@ -16,6 +16,7 @@ from bot.app.web.webapp import (
routes as _routes, routes as _routes,
serializers as _serializers, serializers as _serializers,
support as _support, support as _support,
telegram_notifications as _telegram_notifications,
) )
_MODULES = ( _MODULES = (
@@ -25,6 +26,7 @@ _MODULES = (
_assets, _assets,
_auth, _auth,
_account, _account,
_telegram_notifications,
_serializers, _serializers,
_billing, _billing,
_devices, _devices,
+3
View File
@@ -8,6 +8,7 @@ from .auth import (
_sync_merged_panel_identity_for_user, _sync_merged_panel_identity_for_user,
) )
from .common import _invalidate_webapp_user_caches 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: 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: except Exception:
logger.exception("Failed to send account Telegram linked notification") 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)) token = create_webapp_session_token(settings, int(final_user_id))
response_payload: Dict[str, Any] = { response_payload: Dict[str, Any] = {
"ok": True, "ok": True,
+5
View File
@@ -1,6 +1,7 @@
# ruff: noqa: F401,F403,F405,I001 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 from ._runtime import * # noqa: F403,F405
from .common import _invalidate_webapp_user_caches 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]: 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, 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)) token = create_webapp_session_token(settings, int(final_user_id))
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success")) response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
_clear_telegram_oauth_state_cookie(response) _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") return _json_error(500, "auth_failed", "Auth failed")
await _invalidate_webapp_user_caches(settings, authenticated_user_id, include_devices=True) 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)) token = create_webapp_session_token(settings, int(authenticated_user_id))
return _build_webapp_auth_response(settings, {"ok": True}, token=token) return _build_webapp_auth_response(settings, {"ok": True}, token=token)
+4
View File
@@ -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/request", account_password_request_route)
app.router.add_post("/api/account/password/confirm", account_password_confirm_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/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/promo/apply", apply_promo_route)
app.router.add_post("/api/trial/activate", activate_trial_route) app.router.add_post("/api/trial/activate", activate_trial_route)
app.router.add_get("/api/devices", devices_route) app.router.add_get("/api/devices", devices_route)
+18
View File
@@ -3,6 +3,12 @@ from ._runtime import * # noqa: F403,F405
from config.subscription_guides_config import subscription_guides_available from config.subscription_guides_config import subscription_guides_available
from config.webapp_themes_config import public_themes_catalog_payload 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]: 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) lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])} 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) 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 { return {
"user": { "user": {
"id": user_id, "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_id": db_user.telegram_id,
"telegram_linked": bool(_telegram_id_for_user(db_user)), "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), "telegram_photo_url": _telegram_avatar_url(avatar),
"first_name": db_user.first_name, "first_name": db_user.first_name,
"language_code": lang, "language_code": lang,
@@ -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})
+28 -2
View File
@@ -22,6 +22,7 @@ from bot.services.panel_api_service import PanelApiService
from bot.services.promo_code_service import PromoCodeService from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService 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.callback_answer import safe_answer_callback
from bot.utils.install_links import ( from bot.utils.install_links import (
append_install_share_link_text, 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"^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"^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"^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"^page_ref$").as_("page_ref_match")))
@router.message( @router.message(
CommandStart( CommandStart(
magic=F.args.regexp( 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") ).as_("ad_param_match")
) )
) )
@@ -414,6 +416,7 @@ async def start_command_handler(
ad_param_match: Optional[re.Match] = None, ad_param_match: Optional[re.Match] = None,
admin_user_match: Optional[re.Match] = None, admin_user_match: Optional[re.Match] = None,
ticket_match: Optional[re.Match] = None, ticket_match: Optional[re.Match] = None,
notifications_match: Optional[re.Match] = None,
): ):
await state.clear() await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) 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 promo_code_to_apply: Optional[str] = None
should_open_referral_from_start = False should_open_referral_from_start = False
ad_start_param: Optional[str] = None ad_start_param: Optional[str] = None
notifications_start_requested = bool(notifications_match)
if ref_match: if ref_match:
raw_ref_value = ref_match.group(1) raw_ref_value = ref_match.group(1)
@@ -522,6 +526,8 @@ async def start_command_handler(
elif promo_match: elif promo_match:
promo_code_to_apply = promo_match.group(1) promo_code_to_apply = promo_match.group(1)
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}") 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: elif page_ref_match:
should_open_referral_from_start = True should_open_referral_from_start = True
logging.info(f"User {user_id} started with page_ref deep-link.") 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_username = sanitize_username(user.username)
sanitized_first_name = sanitize_display_name(user.first_name) sanitized_first_name = sanitize_display_name(user.first_name)
sanitized_last_name = sanitize_display_name(user.last_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) db_user = await user_dal.get_user_by_id(session, user_id)
is_existing_user = db_user is not None is_existing_user = db_user is not None
if not db_user: if not db_user:
user_data_to_create = { user_data_to_create = {
"user_id": user_id, "user_id": user_id,
"telegram_id": user_id,
"username": sanitized_username, "username": sanitized_username,
"first_name": sanitized_first_name, "first_name": sanitized_first_name,
"last_name": sanitized_last_name, "last_name": sanitized_last_name,
"language_code": current_lang, "language_code": current_lang,
"referred_by_id": referred_by_user_id, "referred_by_id": referred_by_user_id,
"registration_date": datetime.now(timezone.utc), "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: try:
db_user, created = await user_dal.create_user(session, user_data_to_create) db_user, created = await user_dal.create_user(session, user_data_to_create)
@@ -631,6 +643,13 @@ async def start_command_handler(
update_payload = {} update_payload = {}
if db_user.language_code != current_lang: if db_user.language_code != current_lang:
update_payload["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. # Set referral only if not already set AND user is not currently active.
# This allows previously subscribed but currently inactive users to be attributed. # This allows previously subscribed but currently inactive users to be attributed.
if referred_by_user_id and db_user.referred_by_id is None: 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 open_referral_page_for_existing_user = should_open_referral_from_start and is_existing_user
# Send welcome message if not disabled # 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))) 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 # Auto-apply promo code if provided via start parameter
if promo_code_to_apply: if promo_code_to_apply:
try: try:
@@ -13,6 +13,14 @@ from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.services.email_auth_service import EmailAuthService from bot.services.email_auth_service import EmailAuthService
from bot.services.email_templates import render_subscription_lifecycle_notification 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 config.settings import Settings
from db.dal import subscription_dal from db.dal import subscription_dal
from db.models import Subscription, User from db.models import Subscription, User
@@ -135,32 +143,31 @@ class SubscriptionLifecycleNotificationService:
chat_id = self._telegram_chat_id(user, getattr(sub, "user_id", None)) chat_id = self._telegram_chat_id(user, getattr(sub, "user_id", None))
if chat_id is None: if chat_id is None:
return False 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"): if await self._already_sent(session, sub.subscription_id, stage.key, "telegram"):
return False return False
try: try:
await self.bot.send_message(chat_id, message_text, reply_markup=markup) await self.bot.send_message(chat_id, message_text, reply_markup=markup)
except (TelegramBadRequest, TelegramForbiddenError) as exc: 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( logging.warning(
"Skipping subscription notification %s for unreachable Telegram user %s: %s", "Skipping subscription notification %s for unreachable Telegram user %s: %s",
stage.key, stage.key,
chat_id, chat_id,
exc, 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 return False
logging.exception( logging.exception(
"Failed to send subscription notification %s to Telegram user %s", "Failed to send subscription notification %s to Telegram user %s",
@@ -181,6 +188,18 @@ class SubscriptionLifecycleNotificationService:
self._channel_key(stage.key, "telegram"), self._channel_key(stage.key, "telegram"),
sent_at=sent_at, 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 return True
async def _send_email( async def _send_email(
@@ -333,23 +352,6 @@ class SubscriptionLifecycleNotificationService:
return chat_id return chat_id
return None 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 @staticmethod
def _as_utc(value: Optional[datetime]) -> Optional[datetime]: def _as_utc(value: Optional[datetime]) -> Optional[datetime]:
if value is None: if value is None:
@@ -19,6 +19,14 @@ from bot.services.subscription_lifecycle_notifications import (
SubscriptionNotificationStage, SubscriptionNotificationStage,
) )
from bot.services.subscription_service import SubscriptionService 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 bot.services.user_email_notifications import send_user_notification_email
from config.settings import Settings from config.settings import Settings
from db.advisory_locks import acquire_subscription_background_sync_lock from db.advisory_locks import acquire_subscription_background_sync_lock
@@ -245,6 +253,7 @@ class SubscriptionNotificationWorker:
if limit <= 0 or used < limit: if limit <= 0 or used < limit:
continue continue
delivery = await self._send_trial_traffic_depleted( delivery = await self._send_trial_traffic_depleted(
session,
sub, sub,
used=used, used=used,
limit=limit, limit=limit,
@@ -284,6 +293,7 @@ class SubscriptionNotificationWorker:
async def _send_trial_traffic_depleted( async def _send_trial_traffic_depleted(
self, self,
session: AsyncSession,
sub: Subscription, sub: Subscription,
*, *,
used: int, used: int,
@@ -304,19 +314,38 @@ class SubscriptionNotificationWorker:
) )
telegram_sent = False telegram_sent = False
email_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: try:
await self.bot.send_message( await self.bot.send_message(
user_id, telegram_chat_id,
message_text, message_text,
reply_markup=get_subscribe_only_markup(lang, self.i18n), reply_markup=get_subscribe_only_markup(lang, self.i18n),
parse_mode="HTML", parse_mode="HTML",
) )
telegram_sent = True 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( logging.exception(
"Failed to send trial traffic depleted warning to user %s", "Failed to send trial traffic depleted warning to user %s",
telegram_chat_id,
)
else:
if user and telegram_status != TELEGRAM_NOTIFICATIONS_ENABLED and user_id:
await mark_telegram_notifications_status(
session,
user_id, user_id,
TELEGRAM_NOTIFICATIONS_ENABLED,
telegram_id=telegram_chat_id,
) )
if send_email and user: if send_email and user:
email_sent = await send_user_notification_email( email_sent = await send_user_notification_email(
@@ -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),
}
+21
View File
@@ -408,6 +408,27 @@ async def merge_users(
target.channel_subscription_checked_at = source.channel_subscription_checked_at target.channel_subscription_checked_at = source.channel_subscription_checked_at
if not target.channel_subscription_verified_for and source.channel_subscription_verified_for: if not target.channel_subscription_verified_for and source.channel_subscription_verified_for:
target.channel_subscription_verified_for = 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: if source.lifetime_used_traffic_bytes is not None:
target.lifetime_used_traffic_bytes = ( target.lifetime_used_traffic_bytes = (
target.lifetime_used_traffic_bytes or 0 target.lifetime_used_traffic_bytes or 0
+19
View File
@@ -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] = [ MIGRATIONS: List[Migration] = [
Migration( Migration(
id="0001_add_channel_subscription_fields", id="0001_add_channel_subscription_fields",
@@ -1214,6 +1228,11 @@ MIGRATIONS: List[Migration] = [
description="Track sent subscription notification stages", description="Track sent subscription notification stages",
upgrade=_migration_0031_add_subscription_notifications, 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,
),
] ]
+4
View File
@@ -33,6 +33,10 @@ class User(Base):
password_set_at = Column(DateTime(timezone=True), nullable=True) password_set_at = Column(DateTime(timezone=True), nullable=True)
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True) telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
telegram_photo_url = Column(Text, nullable=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) first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True) last_name = Column(String, nullable=True)
language_code = Column(String, default="ru") language_code = Column(String, default="ru")
+1
View File
@@ -9,6 +9,7 @@ const stateMocks = new Set([
"no-subscription", "no-subscription",
"trial", "trial",
"devices", "devices",
"notifications",
"auth", "auth",
]); ]);
const routeMocks = new Set([...stateMocks, "guides", "install"]); const routeMocks = new Set([...stateMocks, "guides", "install"]);
+1
View File
@@ -294,6 +294,7 @@ const docsHref = '/getting-started/demo/';
<option value="no-subscription">Нет подписки</option> <option value="no-subscription">Нет подписки</option>
<option value="trial">Доступна пробная подписка</option> <option value="trial">Доступна пробная подписка</option>
<option value="devices">Лимит и докупка устройств</option> <option value="devices">Лимит и докупка устройств</option>
<option value="notifications">Telegram-уведомления</option>
<option value="auth">Вход и регистрация</option> <option value="auth">Вход и регистрация</option>
</select> </select>
</label> </label>
+1
View File
@@ -12,6 +12,7 @@
- [Админка: бэкапы](/demo/admin/backups) - [Админка: бэкапы](/demo/admin/backups)
- [Пробный период](/demo/home?mock=trial) - [Пробный период](/demo/home?mock=trial)
- [Докупка устройств](/demo/devices?mock=devices) - [Докупка устройств](/demo/devices?mock=devices)
- [Запуск бота для Telegram-уведомлений](/demo/home?mock=notifications)
- [Вход и регистрация](/demo/login?mock=auth) - [Вход и регистрация](/demo/login?mock=auth)
## Как собирается ## Как собирается
+68 -1
View File
@@ -73,6 +73,7 @@
const ACTIVATION_PENDING_WATCH_INTERVAL_MS = 2000; const ACTIVATION_PENDING_WATCH_INTERVAL_MS = 2000;
const ACTIVATION_PENDING_WATCH_MAX_ATTEMPTS = 45; const ACTIVATION_PENDING_WATCH_MAX_ATTEMPTS = 45;
const ACTIVATION_RESUME_CHECK_COOLDOWN_MS = 1500; const ACTIVATION_RESUME_CHECK_COOLDOWN_MS = 1500;
const TELEGRAM_NOTIFICATIONS_RESUME_REFRESH_COOLDOWN_MS = 1500;
import { import {
activationPaymentFailed, activationPaymentFailed,
createActivationHandoff, createActivationHandoff,
@@ -167,6 +168,9 @@
let activationPendingWatchBusy = false; let activationPendingWatchBusy = false;
let activationResumeRefreshBusy = false; let activationResumeRefreshBusy = false;
let activationResumeLastCheckAt = 0; let activationResumeLastCheckAt = 0;
let telegramNotificationsBotOpenedAt = 0;
let telegramNotificationsResumeRefreshBusy = false;
let telegramNotificationsResumeLastCheckAt = 0;
let promoCode = ""; let promoCode = "";
let promoBusy = false; let promoBusy = false;
let promoStatus = ""; let promoStatus = "";
@@ -475,7 +479,13 @@
languageOptions.find((option) => option.value === currentLang) || languageOptions[0]; languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
$: userLanguage = languageName(currentLang); $: userLanguage = languageName(currentLang);
$: emailLinkStatus = user?.email ? t("wa_settings_linked") : t("wa_settings_email_not_linked"); $: 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 : []; $: referralBonusDetails = Array.isArray(referral?.bonus_details) ? referral.bonus_details : [];
$: referralWelcomeBonusDays = Math.max(0, Number(referral?.welcome_bonus_days || 0)); $: referralWelcomeBonusDays = Math.max(0, Number(referral?.welcome_bonus_days || 0));
$: referralOneBonusPerReferee = Boolean(referral?.one_bonus_per_referee); $: 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() { function refreshAppLaunchTarget() {
appLaunchTarget = readExternalAppLaunchTarget(); appLaunchTarget = readExternalAppLaunchTarget();
return appLaunchTarget; return appLaunchTarget;
@@ -771,6 +809,7 @@
const onActivationResume = () => { const onActivationResume = () => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") return; if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
void refreshPendingActivationOnResume(); void refreshPendingActivationOnResume();
void refreshTelegramNotificationsOnResume();
}; };
const onVisibilityChange = () => { const onVisibilityChange = () => {
if (document.visibilityState !== "hidden") onActivationResume(); 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() { function currentSearchParams() {
return new URLSearchParams(window.location.search); return new URLSearchParams(window.location.search);
} }
@@ -2207,10 +2266,14 @@
{regularTrafficTopupBarClickable} {regularTrafficTopupBarClickable}
{regularTrafficTopupUnlocked} {regularTrafficTopupUnlocked}
{subscription} {subscription}
{telegramNotificationsNeedPrompt}
{telegramNotificationsStartLink}
{telegramNotificationsStatus}
{termUnitLabel} {termUnitLabel}
{trafficMode} {trafficMode}
{trialBusy} {trialBusy}
{activateTrial} {activateTrial}
{openTelegramNotificationsBot}
openConnectLink={openInstallOrConnect} openConnectLink={openInstallOrConnect}
{openPaymentModal} {openPaymentModal}
{openRegularTopupModal} {openRegularTopupModal}
@@ -2314,12 +2377,16 @@
{profileEmail} {profileEmail}
{profileTelegramId} {profileTelegramId}
{supportUrl} {supportUrl}
{telegramNotificationsNeedPrompt}
{telegramNotificationsStartLink}
{telegramNotificationsStatus}
{telegramProfileName} {telegramProfileName}
{user} {user}
{userAgreementUrl} {userAgreementUrl}
{userLanguage} {userLanguage}
showLogout={!telegramMiniAppContext} showLogout={!telegramMiniAppContext}
linkTelegramAccount={linkTelegramFromSettings} linkTelegramAccount={linkTelegramFromSettings}
{openTelegramNotificationsBot}
logout={accountStore.logout} logout={accountStore.logout}
{openAdminPanel} {openAdminPanel}
{openExternalLink} {openExternalLink}
+33
View File
@@ -103,6 +103,10 @@ function applyDemoEmailAuthUser() {
user_id: DEMO_DATASET.currentUser?.user_id || DEMO_DATASET.currentUser?.id || 910001, user_id: DEMO_DATASET.currentUser?.user_id || DEMO_DATASET.currentUser?.id || 910001,
telegram_id: null, telegram_id: null,
telegram_linked: false, 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: "", telegram_photo_url: "",
avatar_url: "", avatar_url: "",
username: DEMO_DATASET.currentUser?.username || "u3252a8", username: DEMO_DATASET.currentUser?.username || "u3252a8",
@@ -181,6 +185,10 @@ function applyDemoTelegramAuthUser(authData = {}) {
user_id: adminUser.user_id || adminUser.id || 910001, user_id: adminUser.user_id || adminUser.id || 910001,
telegram_id: telegramId, telegram_id: telegramId,
telegram_linked: true, 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, username,
first_name: firstName, first_name: firstName,
last_name: lastName, last_name: lastName,
@@ -258,6 +266,10 @@ function applyDemoTelegramLink(authData = {}) {
user_id: DEV_MOCK.data.user?.user_id || DEV_MOCK.data.user?.id || 910001, user_id: DEV_MOCK.data.user?.user_id || DEV_MOCK.data.user?.id || 910001,
telegram_id: telegramId, telegram_id: telegramId,
telegram_linked: true, 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: username:
authData.username || authData.username ||
authDemo.telegram_username || authDemo.telegram_username ||
@@ -1991,6 +2003,27 @@ export async function mockApi(path, options = {}, context = {}) {
applyDemoTelegramLink(body.auth_data || {}); applyDemoTelegramLink(body.auth_data || {});
return { ok: true, csrf_token: "local-preview-csrf" }; 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") { if (path === "/payments" && String(options.method || "").toUpperCase() === "POST") {
const body = jsonBody(options); const body = jsonBody(options);
if (isDeviceTopupSaleMode(body.sale_mode)) { if (isDeviceTopupSaleMode(body.sale_mode)) {
+37
View File
@@ -288,6 +288,10 @@ export const DEV_MOCK = {
password_auth_enabled: false, password_auth_enabled: false,
telegram_id: 100200300, telegram_id: 100200300,
telegram_linked: true, 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: "", telegram_photo_url: "",
first_name: "Preview", first_name: "Preview",
language_code: "ru", language_code: "ru",
@@ -457,6 +461,15 @@ function applyDemoDataset() {
...(DEMO_DATASET.currentUser || {}), ...(DEMO_DATASET.currentUser || {}),
id: DEMO_DATASET.currentUser?.id ?? DEMO_DATASET.currentUser?.user_id, id: DEMO_DATASET.currentUser?.id ?? DEMO_DATASET.currentUser?.user_id,
language_code: storedLanguage || DEMO_DATASET.currentUser?.language_code || "ru", 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 160
); );
@@ -588,6 +601,30 @@ export function applyPreviewMock(kind) {
return; 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") { if (mode === "tariffs") {
DEV_MOCK.data.settings.traffic_mode = false; DEV_MOCK.data.settings.traffic_mode = false;
if (DEMO_DATASET.plans?.length) { if (DEMO_DATASET.plans?.length) {
+72
View File
@@ -1957,6 +1957,78 @@ a {
justify-content: center; 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 { .attention-wrap {
position: relative; position: relative;
} }
@@ -0,0 +1,42 @@
<script>
import { Send } from "$components/ui/icons.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import { AttentionDot } from "$components/ui/index.js";
export let startLink = "";
export let status = "unknown";
export let onOpenBot = () => {};
export let t = (key) => key;
$: isBlocked = status === "blocked";
$: title = isBlocked
? t("wa_telegram_notifications_blocked_title")
: t("wa_telegram_notifications_banner_title");
$: description = isBlocked
? t("wa_telegram_notifications_blocked_text")
: t("wa_telegram_notifications_banner_text");
</script>
<Card class="telegram-notifications-card attention-wrap">
<AttentionDot class="telegram-notifications-dot" />
<div class="telegram-notifications-icon">
<Send size={20} />
</div>
<div class="telegram-notifications-copy">
<strong>{title}</strong>
<small>{description}</small>
</div>
<div class="telegram-notifications-actions">
<Button
variant="telegram"
size="sm"
class="telegram-notifications-primary"
onclick={onOpenBot}
data-start-link={startLink}
>
<Send size={15} />
{t("wa_telegram_notifications_open_bot")}
</Button>
</div>
</Card>
@@ -13,6 +13,7 @@
import BrandMark from "$lib/webapp/BrandMark.svelte"; import BrandMark from "$lib/webapp/BrandMark.svelte";
import Button from "$components/ui/button.svelte"; import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte"; import Card from "$components/ui/card.svelte";
import TelegramNotificationsBanner from "../TelegramNotificationsBanner.svelte";
import { LinearProgress } from "$components/patterns/webapp/index.js"; import { LinearProgress } from "$components/patterns/webapp/index.js";
import { formatTrafficGb } from "../../lib/webapp/formatters.js"; import { formatTrafficGb } from "../../lib/webapp/formatters.js";
import { import {
@@ -38,6 +39,9 @@
export let hasActiveTariffSubscription = false; export let hasActiveTariffSubscription = false;
export let hasMultipleTariffs = false; export let hasMultipleTariffs = false;
export let subscription = {}; export let subscription = {};
export let telegramNotificationsNeedPrompt = false;
export let telegramNotificationsStartLink = "";
export let telegramNotificationsStatus = "unknown";
export let trafficMode = false; export let trafficMode = false;
export let trialBusy = 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. 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 activateTrial = () => {};
export let openConnectLink = () => {}; export let openConnectLink = () => {};
export let openPaymentModal = () => {}; export let openPaymentModal = () => {};
export let openTelegramNotificationsBot = () => {};
export let openRegularTopupModal = () => {}; export let openRegularTopupModal = () => {};
export let openPremiumTopupModal = () => {}; export let openPremiumTopupModal = () => {};
export let openTariffChangeModal = () => {}; export let openTariffChangeModal = () => {};
@@ -123,6 +128,15 @@
<h1>{brandTitle}</h1> <h1>{brandTitle}</h1>
</div> </div>
{#if telegramNotificationsNeedPrompt}
<TelegramNotificationsBanner
startLink={telegramNotificationsStartLink}
status={telegramNotificationsStatus}
onOpenBot={openTelegramNotificationsBot}
{t}
/>
{/if}
<div class="home-bottom"> <div class="home-bottom">
<Card class={`status-card${subscription.active ? "" : " status-card-inactive"}`}> <Card class={`status-card${subscription.active ? "" : " status-card-inactive"}`}>
{#if subscription.active} {#if subscription.active}
@@ -13,6 +13,7 @@
import Card from "$components/ui/card.svelte"; import Card from "$components/ui/card.svelte";
import { AttentionDot } from "$components/ui/index.js"; import { AttentionDot } from "$components/ui/index.js";
import { LanguageSelect } from "$components/patterns/webapp/index.js"; import { LanguageSelect } from "$components/patterns/webapp/index.js";
import TelegramNotificationsBanner from "../TelegramNotificationsBanner.svelte";
export let currentLang = "ru"; export let currentLang = "ru";
export let currentLanguageOption = null; export let currentLanguageOption = null;
@@ -30,6 +31,9 @@
export let profileEmail = ""; export let profileEmail = "";
export let profileTelegramId = ""; export let profileTelegramId = "";
export let supportUrl = ""; export let supportUrl = "";
export let telegramNotificationsNeedPrompt = false;
export let telegramNotificationsStartLink = "";
export let telegramNotificationsStatus = "unknown";
export let telegramProfileName = ""; export let telegramProfileName = "";
export let user = {}; export let user = {};
export let userAgreementUrl = ""; export let userAgreementUrl = "";
@@ -37,6 +41,7 @@
export let showLogout = true; export let showLogout = true;
export let linkTelegramAccount = () => {}; export let linkTelegramAccount = () => {};
export let openTelegramNotificationsBot = () => {};
export let logout = () => {}; export let logout = () => {};
export let openAdminPanel = () => {}; export let openAdminPanel = () => {};
export let openExternalLink = () => {}; export let openExternalLink = () => {};
@@ -67,6 +72,14 @@
<small>{profileTelegramId}</small> <small>{profileTelegramId}</small>
</div> </div>
</Card> </Card>
{#if telegramNotificationsNeedPrompt}
<TelegramNotificationsBanner
startLink={telegramNotificationsStartLink}
status={telegramNotificationsStatus}
onOpenBot={openTelegramNotificationsBot}
{t}
/>
{/if}
{#if isAdmin} {#if isAdmin}
<div class="settings-admin-block"> <div class="settings-admin-block">
<div class="settings-divider" aria-hidden="true"></div> <div class="settings-divider" aria-hidden="true"></div>
+9
View File
@@ -7,6 +7,9 @@
"channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.", "channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.",
"webapp_auth_success": "✅ Login confirmed. Return to the Web App.", "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.", "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.", "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_activate_trial_button": "🆓 Free Trial",
"menu_subscribe_inline": "🚀 Purchase", "menu_subscribe_inline": "🚀 Purchase",
@@ -874,6 +877,12 @@
"wa_settings_telegram_linked_title": "Telegram linked", "wa_settings_telegram_linked_title": "Telegram linked",
"wa_settings_link_email_action": "Link email", "wa_settings_link_email_action": "Link email",
"wa_settings_email_linked_title": "Email linked", "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_activate_promo_title": "Activate promo code",
"wa_traffic_reset_none": "No reset", "wa_traffic_reset_none": "No reset",
"wa_traffic_reset_monthly": "Monthly reset", "wa_traffic_reset_monthly": "Monthly reset",
+9
View File
@@ -7,6 +7,9 @@
"channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.", "channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.",
"webapp_auth_success": "✅ Вход подтвержден. Вернитесь в Web App.", "webapp_auth_success": "✅ Вход подтвержден. Вернитесь в Web App.",
"webapp_auth_expired": "Ссылка авторизации устарела. Вернитесь в 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Перейдите в \"Личный кабинет\", чтобы настроить подключение, посмотреть подписку или продлить доступ.", "main_menu_greeting": "Привет, {user_name}! 👋\nПерейдите в \"Личный кабинет\", чтобы настроить подключение, посмотреть подписку или продлить доступ.",
"menu_activate_trial_button": "🆓 Пробный период", "menu_activate_trial_button": "🆓 Пробный период",
"menu_subscribe_inline": "🚀 Купить", "menu_subscribe_inline": "🚀 Купить",
@@ -874,6 +877,12 @@
"wa_settings_telegram_linked_title": "Телеграм привязан", "wa_settings_telegram_linked_title": "Телеграм привязан",
"wa_settings_link_email_action": "Привязать почту", "wa_settings_link_email_action": "Привязать почту",
"wa_settings_email_linked_title": "Почта привязана", "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_activate_promo_title": "Активировать промокод",
"wa_traffic_reset_none": "Без сброса", "wa_traffic_reset_none": "Без сброса",
"wa_traffic_reset_monthly": "Сброс ежемесячно", "wa_traffic_reset_monthly": "Сброс ежемесячно",
+6
View File
@@ -438,6 +438,11 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase):
"bot.services.notification_service.NotificationService", "bot.services.notification_service.NotificationService",
return_value=notification_service, 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) response = await account_routes.account_telegram_link_route(request)
@@ -453,6 +458,7 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase):
source_user_id=-100, source_user_id=-100,
target_user_id=42, target_user_id=42,
) )
probe_telegram_notifications.assert_awaited_once_with(request, 42)
self.assertEqual(panel_calls, ["delete", "update"]) self.assertEqual(panel_calls, ["delete", "update"])
panel_service.delete_user_from_panel.assert_awaited_once_with( panel_service.delete_user_from_panel.assert_awaited_once_with(
"panel-email", "panel-email",
@@ -108,6 +108,7 @@ def _user(**overrides):
def test_send_stage_records_telegram_and_email_channel_keys(monkeypatch): def test_send_stage_records_telegram_and_email_channel_keys(monkeypatch):
recorded = [] recorded = []
status_changes = []
async def fake_has(session, subscription_id, notification_key): async def fake_has(session, subscription_id, notification_key):
return notification_key in recorded 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): async def fake_record(session, subscription_id, notification_key, *, sent_at=None):
recorded.append(notification_key) 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, "has_subscription_notification", fake_has)
monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record) monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record)
monkeypatch.setattr(lifecycle, "mark_telegram_notifications_status", fake_mark_status)
bot = FakeBot() bot = FakeBot()
email_service = FakeEmailService() 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 email_service.messages[0]["email"] == "user@example.test"
assert "Mirrored Telegram notice" in email_service.messages[0]["content"].html assert "Mirrored Telegram notice" in email_service.messages[0]["content"].html
assert recorded == ["before_3d:telegram", "before_3d:email"] 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): 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"] 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 = [] recorded = []
status_changes = []
async def fake_has(session, subscription_id, notification_key): async def fake_has(session, subscription_id, notification_key):
return notification_key in recorded 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): async def fake_record(session, subscription_id, notification_key, *, sent_at=None):
recorded.append(notification_key) 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, "has_subscription_notification", fake_has)
monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record) monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record)
monkeypatch.setattr(lifecycle, "mark_telegram_notifications_status", fake_mark_status)
bot = ChatNotFoundBot() bot = ChatNotFoundBot()
settings = _settings() 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.telegram_sent is False
assert delivery.email_sent is False assert delivery.email_sent is False
assert bot.calls[0][0] == 777 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): def test_email_only_user_gets_email_name_direct_copy_and_renewal_login_link(monkeypatch):
+24
View File
@@ -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