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,
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,
+3
View File
@@ -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,
+5
View File
@@ -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)
+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/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)
+18
View File
@@ -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,
@@ -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.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:
@@ -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:
@@ -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,
@@ -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
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
+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] = [
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,
),
]
+4
View File
@@ -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")
+1
View File
@@ -9,6 +9,7 @@ const stateMocks = new Set([
"no-subscription",
"trial",
"devices",
"notifications",
"auth",
]);
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="trial">Доступна пробная подписка</option>
<option value="devices">Лимит и докупка устройств</option>
<option value="notifications">Telegram-уведомления</option>
<option value="auth">Вход и регистрация</option>
</select>
</label>
+1
View File
@@ -12,6 +12,7 @@
- [Админка: бэкапы](/demo/admin/backups)
- [Пробный период](/demo/home?mock=trial)
- [Докупка устройств](/demo/devices?mock=devices)
- [Запуск бота для Telegram-уведомлений](/demo/home?mock=notifications)
- [Вход и регистрация](/demo/login?mock=auth)
## Как собирается
+68 -1
View File
@@ -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}
+33
View File
@@ -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)) {
+37
View File
@@ -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) {
+72
View File
@@ -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;
}
@@ -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 Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import TelegramNotificationsBanner from "../TelegramNotificationsBanner.svelte";
import { LinearProgress } from "$components/patterns/webapp/index.js";
import { formatTrafficGb } from "../../lib/webapp/formatters.js";
import {
@@ -38,6 +39,9 @@
export let hasActiveTariffSubscription = false;
export let hasMultipleTariffs = false;
export let subscription = {};
export let telegramNotificationsNeedPrompt = false;
export let telegramNotificationsStartLink = "";
export let telegramNotificationsStatus = "unknown";
export let trafficMode = false;
export let trialBusy = false;
export let termUnitLabel = () => ""; // We need this passed from App or context. Actually, App.svelte doesn't pass it yet. We'll pass it.
@@ -110,6 +114,7 @@
export let activateTrial = () => {};
export let openConnectLink = () => {};
export let openPaymentModal = () => {};
export let openTelegramNotificationsBot = () => {};
export let openRegularTopupModal = () => {};
export let openPremiumTopupModal = () => {};
export let openTariffChangeModal = () => {};
@@ -123,6 +128,15 @@
<h1>{brandTitle}</h1>
</div>
{#if telegramNotificationsNeedPrompt}
<TelegramNotificationsBanner
startLink={telegramNotificationsStartLink}
status={telegramNotificationsStatus}
onOpenBot={openTelegramNotificationsBot}
{t}
/>
{/if}
<div class="home-bottom">
<Card class={`status-card${subscription.active ? "" : " status-card-inactive"}`}>
{#if subscription.active}
@@ -13,6 +13,7 @@
import Card from "$components/ui/card.svelte";
import { AttentionDot } from "$components/ui/index.js";
import { LanguageSelect } from "$components/patterns/webapp/index.js";
import TelegramNotificationsBanner from "../TelegramNotificationsBanner.svelte";
export let currentLang = "ru";
export let currentLanguageOption = null;
@@ -30,6 +31,9 @@
export let profileEmail = "";
export let profileTelegramId = "";
export let supportUrl = "";
export let telegramNotificationsNeedPrompt = false;
export let telegramNotificationsStartLink = "";
export let telegramNotificationsStatus = "unknown";
export let telegramProfileName = "";
export let user = {};
export let userAgreementUrl = "";
@@ -37,6 +41,7 @@
export let showLogout = true;
export let linkTelegramAccount = () => {};
export let openTelegramNotificationsBot = () => {};
export let logout = () => {};
export let openAdminPanel = () => {};
export let openExternalLink = () => {};
@@ -67,6 +72,14 @@
<small>{profileTelegramId}</small>
</div>
</Card>
{#if telegramNotificationsNeedPrompt}
<TelegramNotificationsBanner
startLink={telegramNotificationsStartLink}
status={telegramNotificationsStatus}
onOpenBot={openTelegramNotificationsBot}
{t}
/>
{/if}
{#if isAdmin}
<div class="settings-admin-block">
<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.",
"webapp_auth_success": "✅ Login confirmed. Return to the Web App.",
"webapp_auth_expired": "The login link has expired. Return to the Web App and try again.",
"telegram_notifications_enabled_message": "✅ Telegram notifications are enabled.",
"telegram_notifications_open_app_button": "Open account",
"telegram_notifications_started": "✅ Notifications are enabled. The bot can now send important messages.",
"main_menu_greeting": "Hi, {user_name}! 👋\nOpen \"Personal Account\" to set up the connection, view your subscription, or renew access.",
"menu_activate_trial_button": "🆓 Free Trial",
"menu_subscribe_inline": "🚀 Purchase",
@@ -874,6 +877,12 @@
"wa_settings_telegram_linked_title": "Telegram linked",
"wa_settings_link_email_action": "Link email",
"wa_settings_email_linked_title": "Email linked",
"wa_telegram_notifications_banner_title": "Enable Telegram notifications",
"wa_telegram_notifications_banner_text": "The bot can send subscription, payment, and support updates only after you allow messages or start the chat.",
"wa_telegram_notifications_blocked_title": "The bot cannot send notifications",
"wa_telegram_notifications_blocked_text": "Open the bot in Telegram and unblock the chat, then return to the app.",
"wa_telegram_notifications_open_bot": "Start bot",
"wa_telegram_notifications_link_unavailable": "Bot link is not available yet",
"wa_activate_promo_title": "Activate promo code",
"wa_traffic_reset_none": "No reset",
"wa_traffic_reset_monthly": "Monthly reset",
+9
View File
@@ -7,6 +7,9 @@
"channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.",
"webapp_auth_success": "✅ Вход подтвержден. Вернитесь в Web App.",
"webapp_auth_expired": "Ссылка авторизации устарела. Вернитесь в Web App и попробуйте еще раз.",
"telegram_notifications_enabled_message": "✅ Уведомления в Telegram включены.",
"telegram_notifications_open_app_button": "Открыть кабинет",
"telegram_notifications_started": "✅ Уведомления включены. Теперь бот сможет присылать важные сообщения.",
"main_menu_greeting": "Привет, {user_name}! 👋\nПерейдите в \"Личный кабинет\", чтобы настроить подключение, посмотреть подписку или продлить доступ.",
"menu_activate_trial_button": "🆓 Пробный период",
"menu_subscribe_inline": "🚀 Купить",
@@ -874,6 +877,12 @@
"wa_settings_telegram_linked_title": "Телеграм привязан",
"wa_settings_link_email_action": "Привязать почту",
"wa_settings_email_linked_title": "Почта привязана",
"wa_telegram_notifications_banner_title": "Включите уведомления в Telegram",
"wa_telegram_notifications_banner_text": "Бот пришлет напоминания о подписке, оплатах и ответах поддержки только после разрешения или запуска чата.",
"wa_telegram_notifications_blocked_title": "Бот не может отправлять уведомления",
"wa_telegram_notifications_blocked_text": "Откройте бота в Telegram и разблокируйте чат, затем вернитесь в приложение.",
"wa_telegram_notifications_open_bot": "Запустить бота",
"wa_telegram_notifications_link_unavailable": "Ссылка на бота пока недоступна",
"wa_activate_promo_title": "Активировать промокод",
"wa_traffic_reset_none": "Без сброса",
"wa_traffic_reset_monthly": "Сброс ежемесячно",
+6
View File
@@ -438,6 +438,11 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase):
"bot.services.notification_service.NotificationService",
return_value=notification_service,
),
patch.object(
account_routes,
"_probe_telegram_notifications_for_user_id",
AsyncMock(),
) as probe_telegram_notifications,
):
response = await account_routes.account_telegram_link_route(request)
@@ -453,6 +458,7 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase):
source_user_id=-100,
target_user_id=42,
)
probe_telegram_notifications.assert_awaited_once_with(request, 42)
self.assertEqual(panel_calls, ["delete", "update"])
panel_service.delete_user_from_panel.assert_awaited_once_with(
"panel-email",
@@ -108,6 +108,7 @@ def _user(**overrides):
def test_send_stage_records_telegram_and_email_channel_keys(monkeypatch):
recorded = []
status_changes = []
async def fake_has(session, subscription_id, notification_key):
return notification_key in recorded
@@ -115,8 +116,19 @@ def test_send_stage_records_telegram_and_email_channel_keys(monkeypatch):
async def fake_record(session, subscription_id, notification_key, *, sent_at=None):
recorded.append(notification_key)
async def fake_mark_status(session, user_id, status, *, telegram_id=None, checked_at=None):
status_changes.append(
{
"user_id": user_id,
"status": status,
"telegram_id": telegram_id,
"checked_at": checked_at,
}
)
monkeypatch.setattr(lifecycle.subscription_dal, "has_subscription_notification", fake_has)
monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record)
monkeypatch.setattr(lifecycle, "mark_telegram_notifications_status", fake_mark_status)
bot = FakeBot()
email_service = FakeEmailService()
@@ -154,6 +166,15 @@ def test_send_stage_records_telegram_and_email_channel_keys(monkeypatch):
assert email_service.messages[0]["email"] == "user@example.test"
assert "Mirrored Telegram notice" in email_service.messages[0]["content"].html
assert recorded == ["before_3d:telegram", "before_3d:email"]
assert status_changes == [
{
"user_id": 123,
"status": lifecycle.TELEGRAM_NOTIFICATIONS_ENABLED,
"telegram_id": 555,
"checked_at": status_changes[0]["checked_at"],
}
]
assert status_changes[0]["checked_at"] is not None
def test_legacy_stage_key_suppresses_only_telegram(monkeypatch):
@@ -199,8 +220,9 @@ def test_legacy_stage_key_suppresses_only_telegram(monkeypatch):
assert recorded == ["before_3d", "before_3d:email"]
def test_terminal_telegram_failure_is_recorded_to_avoid_retry_spam(monkeypatch):
def test_unstarted_telegram_failure_marks_status_without_recording_delivery(monkeypatch):
recorded = []
status_changes = []
async def fake_has(session, subscription_id, notification_key):
return notification_key in recorded
@@ -208,8 +230,19 @@ def test_terminal_telegram_failure_is_recorded_to_avoid_retry_spam(monkeypatch):
async def fake_record(session, subscription_id, notification_key, *, sent_at=None):
recorded.append(notification_key)
async def fake_mark_status(session, user_id, status, *, telegram_id=None, checked_at=None):
status_changes.append(
{
"user_id": user_id,
"status": status,
"telegram_id": telegram_id,
"checked_at": checked_at,
}
)
monkeypatch.setattr(lifecycle.subscription_dal, "has_subscription_notification", fake_has)
monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record)
monkeypatch.setattr(lifecycle, "mark_telegram_notifications_status", fake_mark_status)
bot = ChatNotFoundBot()
settings = _settings()
@@ -241,7 +274,15 @@ def test_terminal_telegram_failure_is_recorded_to_avoid_retry_spam(monkeypatch):
assert delivery.telegram_sent is False
assert delivery.email_sent is False
assert bot.calls[0][0] == 777
assert recorded == ["before_3d:telegram"]
assert recorded == []
assert status_changes == [
{
"user_id": 123,
"status": lifecycle.TELEGRAM_NOTIFICATIONS_NEEDS_START,
"telegram_id": None,
"checked_at": None,
}
]
def test_email_only_user_gets_email_name_direct_copy_and_renewal_login_link(monkeypatch):
+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