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 @@
+
+
+