feat: prompt users to start Telegram bot for notifications
This commit is contained in:
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user