feat: prompt users to start Telegram bot for notifications
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -9,6 +9,7 @@ const stateMocks = new Set([
|
||||
"no-subscription",
|
||||
"trial",
|
||||
"devices",
|
||||
"notifications",
|
||||
"auth",
|
||||
]);
|
||||
const routeMocks = new Set([...stateMocks, "guides", "install"]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Сброс ежемесячно",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user