feat: mirror subscription lifecycle notifications
This commit is contained in:
@@ -396,6 +396,13 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"notifications",
|
"notifications",
|
||||||
"Включены уведомления о подписке",
|
"Включены уведомления о подписке",
|
||||||
),
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"notifications",
|
||||||
|
"Дублировать уведомления о подписке на email",
|
||||||
|
"Письма отправляются только пользователям с привязанным email и рабочим SMTP.",
|
||||||
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
|
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -497,6 +497,101 @@ def render_subscription_expiring(
|
|||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||||
|
|
||||||
|
|
||||||
|
def _subscription_lifecycle_title(
|
||||||
|
i18n: JsonI18n,
|
||||||
|
lang: str,
|
||||||
|
notification_key: str,
|
||||||
|
*,
|
||||||
|
days_left: Optional[int],
|
||||||
|
hours_before: Optional[int],
|
||||||
|
) -> str:
|
||||||
|
if notification_key == "before_2d_autorenew":
|
||||||
|
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_autorenew")
|
||||||
|
if notification_key == "expired":
|
||||||
|
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_expired")
|
||||||
|
if notification_key == "expired_24h_after":
|
||||||
|
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_expired_after")
|
||||||
|
if hours_before is not None:
|
||||||
|
return _t_text(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
"email_subscription_lifecycle_subject_before_hours",
|
||||||
|
hours=hours_before,
|
||||||
|
)
|
||||||
|
return _t_text(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
"email_subscription_lifecycle_subject_before_days",
|
||||||
|
days=max(0, int(days_left or 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_subscription_lifecycle_notification(
|
||||||
|
settings: Settings,
|
||||||
|
*,
|
||||||
|
language_code: Optional[str],
|
||||||
|
notification_key: str,
|
||||||
|
message_text: str,
|
||||||
|
end_date_text: str,
|
||||||
|
dashboard_url: Optional[str],
|
||||||
|
days_left: Optional[int] = None,
|
||||||
|
hours_before: Optional[int] = None,
|
||||||
|
i18n: Optional[JsonI18n] = None,
|
||||||
|
) -> EmailContent:
|
||||||
|
i18n = _resolve_i18n(i18n)
|
||||||
|
lang = _normalize_lang(language_code, settings)
|
||||||
|
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||||
|
brand = _brand_title(settings)
|
||||||
|
safe_dashboard_url = (dashboard_url or "").strip()
|
||||||
|
end_date = end_date_text or "—"
|
||||||
|
subject = _subscription_lifecycle_title(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
notification_key,
|
||||||
|
days_left=days_left,
|
||||||
|
hours_before=hours_before,
|
||||||
|
)
|
||||||
|
intro = _t_text(i18n, lang, "email_subscription_lifecycle_intro")
|
||||||
|
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||||
|
cta_label = _t_text(i18n, lang, "email_subscription_lifecycle_cta")
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
(_t_text(i18n, lang, "email_subscription_lifecycle_row_end_date"), end_date),
|
||||||
|
]
|
||||||
|
message_html = (
|
||||||
|
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
|
||||||
|
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
|
||||||
|
f'white-space:pre-wrap;">{html.escape(message_text or "")}</div>'
|
||||||
|
)
|
||||||
|
body_parts = [_info_rows_html(rows), message_html]
|
||||||
|
if safe_dashboard_url:
|
||||||
|
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
|
||||||
|
|
||||||
|
rendered = _layout(
|
||||||
|
settings=settings,
|
||||||
|
preheader=subject,
|
||||||
|
heading=subject,
|
||||||
|
intro_html=html.escape(intro),
|
||||||
|
body_html="".join(body_parts),
|
||||||
|
footer_html=footer,
|
||||||
|
)
|
||||||
|
|
||||||
|
text_lines = [subject, "", message_text]
|
||||||
|
if safe_dashboard_url:
|
||||||
|
text_lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
_t_text(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
"email_subscription_lifecycle_text_renew",
|
||||||
|
url=safe_dashboard_url,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||||
|
|
||||||
|
|
||||||
def _support_email(
|
def _support_email(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
i18n: Optional[JsonI18n],
|
i18n: Optional[JsonI18n],
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.types import InlineKeyboardMarkup
|
from aiogram.types import InlineKeyboardMarkup
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload, sessionmaker
|
||||||
|
|
||||||
from bot.infra.webhook_queue import enqueue_webhook_event
|
from bot.infra.webhook_queue import enqueue_webhook_event
|
||||||
from bot.keyboards.inline.user_keyboards import (
|
from bot.keyboards.inline.user_keyboards import (
|
||||||
@@ -16,17 +19,32 @@ from bot.keyboards.inline.user_keyboards import (
|
|||||||
get_subscribe_only_markup,
|
get_subscribe_only_markup,
|
||||||
)
|
)
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.subscription_lifecycle_notifications import (
|
||||||
|
SubscriptionLifecycleNotificationService,
|
||||||
|
SubscriptionNotificationStage,
|
||||||
|
)
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import tariff_dal, user_dal
|
from db.dal import subscription_dal, tariff_dal, user_dal
|
||||||
|
from db.models import Subscription, User
|
||||||
|
|
||||||
from .email_auth_service import EmailAuthService
|
|
||||||
from .email_templates import render_subscription_expiring
|
|
||||||
from .panel_api_service import PanelApiService
|
from .panel_api_service import PanelApiService
|
||||||
|
|
||||||
EVENT_MAP = {
|
EVENT_MAP = {
|
||||||
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
|
"user.expires_in_72_hours": SubscriptionNotificationStage(
|
||||||
"user.expires_in_48_hours": (2, "subscription_48h_notification"),
|
key="before_3d",
|
||||||
"user.expires_in_24_hours": (1, "subscription_24h_notification"),
|
message_key="subscription_72h_notification",
|
||||||
|
days_left=3,
|
||||||
|
),
|
||||||
|
"user.expires_in_48_hours": SubscriptionNotificationStage(
|
||||||
|
key="before_2d",
|
||||||
|
message_key="subscription_48h_notification",
|
||||||
|
days_left=2,
|
||||||
|
),
|
||||||
|
"user.expires_in_24_hours": SubscriptionNotificationStage(
|
||||||
|
key="before_1d",
|
||||||
|
message_key="subscription_24h_notification",
|
||||||
|
days_left=1,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -48,6 +66,11 @@ class PanelWebhookService:
|
|||||||
self.i18n = i18n
|
self.i18n = i18n
|
||||||
self.async_session_factory = async_session_factory
|
self.async_session_factory = async_session_factory
|
||||||
self.panel_service = panel_service
|
self.panel_service = panel_service
|
||||||
|
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
|
||||||
|
settings,
|
||||||
|
bot,
|
||||||
|
i18n,
|
||||||
|
)
|
||||||
self._event_semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_EVENTS)
|
self._event_semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_EVENTS)
|
||||||
if not self.settings.PANEL_WEBHOOK_SECRET:
|
if not self.settings.PANEL_WEBHOOK_SECRET:
|
||||||
logging.error(
|
logging.error(
|
||||||
@@ -102,112 +125,181 @@ class PanelWebhookService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def handle_event(self, event_name: str, user_payload: dict):
|
async def handle_event(self, event_name: str, user_payload: dict):
|
||||||
telegram_id = user_payload.get("telegramId")
|
|
||||||
if not telegram_id:
|
|
||||||
logging.warning("Panel webhook without telegramId received")
|
|
||||||
return
|
|
||||||
user_id = int(telegram_id)
|
|
||||||
|
|
||||||
if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED:
|
if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED:
|
||||||
return
|
return
|
||||||
|
|
||||||
async with self.async_session_factory() as session:
|
async with self.async_session_factory() as session:
|
||||||
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
|
db_user = await self._user_for_payload(session, user_payload)
|
||||||
if not db_user:
|
sub = await self._subscription_for_payload(session, user_payload, db_user)
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
telegram_id = self._payload_telegram_id(user_payload)
|
||||||
internal_user_id = db_user.user_id if db_user else user_id
|
internal_user_id = (
|
||||||
|
int(db_user.user_id)
|
||||||
|
if db_user
|
||||||
|
else int(getattr(sub, "user_id", 0) or telegram_id or 0)
|
||||||
|
)
|
||||||
lang = (
|
lang = (
|
||||||
db_user.language_code
|
db_user.language_code
|
||||||
if db_user and db_user.language_code
|
if db_user and db_user.language_code
|
||||||
else self.settings.DEFAULT_LANGUAGE
|
else self.settings.DEFAULT_LANGUAGE
|
||||||
)
|
)
|
||||||
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
if not sub:
|
||||||
user_email = (db_user.email or "").strip() if db_user else ""
|
if not telegram_id:
|
||||||
|
logging.warning("Panel webhook event %s has no local subscription", event_name)
|
||||||
|
return
|
||||||
|
await self._send_legacy_without_dedupe(
|
||||||
|
event_name,
|
||||||
|
user_payload,
|
||||||
|
int(telegram_id),
|
||||||
|
lang,
|
||||||
|
db_user,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
markup = get_subscribe_only_markup(lang, self.i18n)
|
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||||
|
end_date_text = self._payload_expire_date(user_payload)
|
||||||
|
|
||||||
if event_name in EVENT_MAP:
|
if event_name in EVENT_MAP:
|
||||||
days_left, msg_key = EVENT_MAP[event_name]
|
stage = EVENT_MAP[event_name]
|
||||||
|
days_left = int(stage.days_left or 0)
|
||||||
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
|
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
|
||||||
if days_left == 1:
|
if days_left == 1:
|
||||||
# Trigger auto-renew via SubscriptionService (wired in at factory)
|
# Trigger auto-renew via SubscriptionService (wired in at factory)
|
||||||
try:
|
try:
|
||||||
subscription_service = getattr(self, "subscription_service", None)
|
subscription_service = getattr(self, "subscription_service", None)
|
||||||
if subscription_service:
|
if subscription_service:
|
||||||
async with self.async_session_factory() as session:
|
async with self.async_session_factory() as renewal_session:
|
||||||
from db.dal import subscription_dal
|
active_sub = (
|
||||||
|
await subscription_dal.get_active_subscription_by_user_id(
|
||||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
renewal_session,
|
||||||
session, internal_user_id
|
internal_user_id,
|
||||||
)
|
)
|
||||||
if sub and sub.auto_renew_enabled and sub.provider == "yookassa":
|
)
|
||||||
|
if (
|
||||||
|
active_sub
|
||||||
|
and active_sub.auto_renew_enabled
|
||||||
|
and active_sub.provider == "yookassa"
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
ok = await subscription_service.charge_subscription_renewal(
|
ok = await subscription_service.charge_subscription_renewal(
|
||||||
session, sub
|
renewal_session,
|
||||||
|
active_sub,
|
||||||
)
|
)
|
||||||
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
|
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
|
||||||
if ok:
|
if ok:
|
||||||
await session.commit()
|
await renewal_session.commit()
|
||||||
return
|
return
|
||||||
else:
|
await renewal_session.rollback()
|
||||||
await session.rollback()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
await session.rollback()
|
await renewal_session.rollback()
|
||||||
logging.exception("Auto-renew attempt (24h) failed")
|
logging.exception("Auto-renew attempt (24h) failed")
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
||||||
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
||||||
# For 48h event, if auto-renew is enabled, show special notice with cancel button
|
# For 48h, auto-renew users get a cancel button instead.
|
||||||
if days_left == 2:
|
if days_left == 2:
|
||||||
async with self.async_session_factory() as session:
|
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
from db.dal import subscription_dal
|
session,
|
||||||
|
internal_user_id,
|
||||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
|
||||||
session, internal_user_id
|
|
||||||
)
|
)
|
||||||
logging.info(
|
logging.info(
|
||||||
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
|
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
|
||||||
user_id,
|
internal_user_id,
|
||||||
bool(sub),
|
bool(active_sub),
|
||||||
getattr(sub, "auto_renew_enabled", None) if sub else None,
|
getattr(active_sub, "auto_renew_enabled", None) if active_sub else None,
|
||||||
getattr(sub, "provider", None) if sub else None,
|
getattr(active_sub, "provider", None) if active_sub else None,
|
||||||
)
|
)
|
||||||
if sub and sub.auto_renew_enabled and sub.provider == "yookassa":
|
if (
|
||||||
|
active_sub
|
||||||
|
and active_sub.auto_renew_enabled
|
||||||
|
and active_sub.provider == "yookassa"
|
||||||
|
):
|
||||||
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
|
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
|
||||||
await self._send_message(
|
await self.lifecycle_notifications.send_stage(
|
||||||
user_id,
|
session,
|
||||||
lang,
|
sub,
|
||||||
"autorenew_48h_charge_tomorrow_notice",
|
SubscriptionNotificationStage(
|
||||||
reply_markup=cancel_kb,
|
key="before_2d_autorenew",
|
||||||
user_name=first_name,
|
message_key="autorenew_48h_charge_tomorrow_notice",
|
||||||
|
days_left=2,
|
||||||
|
),
|
||||||
|
user=db_user,
|
||||||
|
telegram_markup=cancel_kb,
|
||||||
extra_text=hwid_renewal_note,
|
extra_text=hwid_renewal_note,
|
||||||
|
end_date_text=end_date_text,
|
||||||
)
|
)
|
||||||
|
await session.commit()
|
||||||
return
|
return
|
||||||
await self._send_message(
|
await self.lifecycle_notifications.send_stage(
|
||||||
user_id,
|
session,
|
||||||
lang,
|
sub,
|
||||||
msg_key,
|
stage,
|
||||||
reply_markup=markup,
|
user=db_user,
|
||||||
user_name=first_name,
|
telegram_markup=markup,
|
||||||
end_date=user_payload.get("expireAt", "")[:10],
|
|
||||||
extra_text=hwid_renewal_note,
|
extra_text=hwid_renewal_note,
|
||||||
|
end_date_text=end_date_text,
|
||||||
)
|
)
|
||||||
if days_left == 3 and user_email:
|
await session.commit()
|
||||||
await self._send_subscription_expiring_email(
|
|
||||||
recipient=user_email,
|
|
||||||
lang=lang,
|
|
||||||
days_left=days_left,
|
|
||||||
end_date_text=user_payload.get("expireAt", "")[:10],
|
|
||||||
)
|
|
||||||
elif event_name == "user.expired":
|
elif event_name == "user.expired":
|
||||||
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||||
|
await self.lifecycle_notifications.send_stage(
|
||||||
|
session,
|
||||||
|
sub,
|
||||||
|
SubscriptionNotificationStage(
|
||||||
|
key="expired",
|
||||||
|
message_key="subscription_expired_notification",
|
||||||
|
days_left=0,
|
||||||
|
),
|
||||||
|
user=db_user,
|
||||||
|
telegram_markup=markup,
|
||||||
|
end_date_text=end_date_text,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
elif (
|
||||||
|
event_name == "user.expired_24_hours_ago"
|
||||||
|
and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE
|
||||||
|
):
|
||||||
|
await self.lifecycle_notifications.send_stage(
|
||||||
|
session,
|
||||||
|
sub,
|
||||||
|
SubscriptionNotificationStage(
|
||||||
|
key="expired_24h_after",
|
||||||
|
message_key="subscription_expired_yesterday_notification",
|
||||||
|
days_left=0,
|
||||||
|
),
|
||||||
|
user=db_user,
|
||||||
|
telegram_markup=markup,
|
||||||
|
end_date_text=end_date_text,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def _send_legacy_without_dedupe(
|
||||||
|
self,
|
||||||
|
event_name: str,
|
||||||
|
user_payload: dict,
|
||||||
|
user_id: int,
|
||||||
|
lang: str,
|
||||||
|
db_user: Optional[User],
|
||||||
|
) -> None:
|
||||||
|
first_name = getattr(db_user, "first_name", None) or f"User {user_id}"
|
||||||
|
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||||
|
if event_name in EVENT_MAP:
|
||||||
|
stage = EVENT_MAP[event_name]
|
||||||
|
await self._send_message(
|
||||||
|
user_id,
|
||||||
|
lang,
|
||||||
|
stage.message_key,
|
||||||
|
reply_markup=markup,
|
||||||
|
user_name=first_name,
|
||||||
|
end_date=self._payload_expire_date(user_payload),
|
||||||
|
)
|
||||||
|
elif event_name == "user.expired" and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||||
await self._send_message(
|
await self._send_message(
|
||||||
user_id,
|
user_id,
|
||||||
lang,
|
lang,
|
||||||
"subscription_expired_notification",
|
"subscription_expired_notification",
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
user_name=first_name,
|
user_name=first_name,
|
||||||
end_date=user_payload.get("expireAt", "")[:10],
|
end_date=self._payload_expire_date(user_payload),
|
||||||
)
|
)
|
||||||
elif (
|
elif (
|
||||||
event_name == "user.expired_24_hours_ago"
|
event_name == "user.expired_24_hours_ago"
|
||||||
@@ -219,33 +311,113 @@ class PanelWebhookService:
|
|||||||
"subscription_expired_yesterday_notification",
|
"subscription_expired_yesterday_notification",
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
user_name=first_name,
|
user_name=first_name,
|
||||||
end_date=user_payload.get("expireAt", "")[:10],
|
end_date=self._payload_expire_date(user_payload),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _send_subscription_expiring_email(
|
async def _user_for_payload(
|
||||||
self,
|
self,
|
||||||
*,
|
session: AsyncSession,
|
||||||
recipient: str,
|
user_payload: dict,
|
||||||
lang: str,
|
) -> Optional[User]:
|
||||||
days_left: int,
|
telegram_id = self._payload_telegram_id(user_payload)
|
||||||
end_date_text: str,
|
if telegram_id:
|
||||||
) -> None:
|
user = await user_dal.get_user_by_telegram_id(session, telegram_id)
|
||||||
"""Best-effort branded reminder; silently no-ops without SMTP config."""
|
if user:
|
||||||
if not self.settings.email_auth_configured:
|
return user
|
||||||
return
|
user = await user_dal.get_user_by_id(session, telegram_id)
|
||||||
try:
|
if user:
|
||||||
content = render_subscription_expiring(
|
return user
|
||||||
self.settings,
|
|
||||||
language_code=lang,
|
panel_uuid = self._payload_panel_uuid(user_payload)
|
||||||
days_left=days_left,
|
if panel_uuid:
|
||||||
end_date_text=end_date_text,
|
user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
|
||||||
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
|
if user:
|
||||||
i18n=self.i18n,
|
return user
|
||||||
|
|
||||||
|
email = str(user_payload.get("email") or "").strip()
|
||||||
|
if email:
|
||||||
|
return await user_dal.get_user_by_email(session, email)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _subscription_for_payload(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_payload: dict,
|
||||||
|
db_user: Optional[User],
|
||||||
|
) -> Optional[Subscription]:
|
||||||
|
conditions = []
|
||||||
|
if db_user:
|
||||||
|
conditions.append(Subscription.user_id == db_user.user_id)
|
||||||
|
panel_uuid = self._payload_panel_uuid(user_payload)
|
||||||
|
if panel_uuid:
|
||||||
|
conditions.append(Subscription.panel_user_uuid == panel_uuid)
|
||||||
|
if not conditions:
|
||||||
|
return None
|
||||||
|
base_stmt = (
|
||||||
|
select(Subscription)
|
||||||
|
.where(
|
||||||
|
Subscription.skip_notifications == False,
|
||||||
|
or_(*conditions),
|
||||||
)
|
)
|
||||||
email_service = EmailAuthService(self.settings, self.i18n)
|
.options(selectinload(Subscription.user))
|
||||||
await email_service.send_rendered_email(email=recipient, content=content)
|
)
|
||||||
except Exception:
|
|
||||||
logging.exception("Failed to send subscription-expiring email to %s", recipient)
|
expire_at = self._payload_expire_datetime(user_payload)
|
||||||
|
if expire_at is not None:
|
||||||
|
window_stmt = (
|
||||||
|
base_stmt.where(
|
||||||
|
Subscription.end_date >= expire_at - timedelta(days=1),
|
||||||
|
Subscription.end_date <= expire_at + timedelta(days=1),
|
||||||
|
)
|
||||||
|
.order_by(Subscription.end_date.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await session.execute(window_stmt)
|
||||||
|
found = result.scalars().first()
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
|
||||||
|
stmt = base_stmt.order_by(Subscription.end_date.desc()).limit(1)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _payload_telegram_id(user_payload: dict) -> Optional[int]:
|
||||||
|
raw = user_payload.get("telegramId")
|
||||||
|
try:
|
||||||
|
value = int(raw or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return value if value > 0 else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _payload_panel_uuid(user_payload: dict) -> str:
|
||||||
|
return str(
|
||||||
|
user_payload.get("uuid")
|
||||||
|
or user_payload.get("userUuid")
|
||||||
|
or user_payload.get("shortUuid")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _payload_expire_date(user_payload: dict) -> str:
|
||||||
|
return str(user_payload.get("expireAt") or "")[:10]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _payload_expire_datetime(user_payload: dict) -> Optional[datetime]:
|
||||||
|
raw = str(user_payload.get("expireAt") or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
value = datetime.fromisoformat(raw[:10])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
async def handle_webhook(
|
async def handle_webhook(
|
||||||
self, raw_body: bytes, signature_header: Optional[str]
|
self, raw_body: bytes, signature_header: Optional[str]
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.types import InlineKeyboardMarkup
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
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 config.settings import Settings
|
||||||
|
from db.dal import subscription_dal
|
||||||
|
from db.models import Subscription, User
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SubscriptionNotificationStage:
|
||||||
|
key: str
|
||||||
|
message_key: str
|
||||||
|
days_left: Optional[int] = None
|
||||||
|
hours_before: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SubscriptionNotificationDelivery:
|
||||||
|
telegram_sent: bool = False
|
||||||
|
email_sent: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def any_sent(self) -> bool:
|
||||||
|
return self.telegram_sent or self.email_sent
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionLifecycleNotificationService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: Settings,
|
||||||
|
bot: Bot,
|
||||||
|
i18n: JsonI18n,
|
||||||
|
*,
|
||||||
|
email_service: Optional[EmailAuthService] = None,
|
||||||
|
) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.bot = bot
|
||||||
|
self.i18n = i18n
|
||||||
|
self.email_service = email_service
|
||||||
|
|
||||||
|
async def send_stage(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
sub: Subscription,
|
||||||
|
stage: SubscriptionNotificationStage,
|
||||||
|
*,
|
||||||
|
user: Optional[User] = None,
|
||||||
|
telegram_markup: Optional[InlineKeyboardMarkup] = None,
|
||||||
|
extra_text: str = "",
|
||||||
|
end_date_text: Optional[str] = None,
|
||||||
|
sent_at: Optional[datetime] = None,
|
||||||
|
) -> SubscriptionNotificationDelivery:
|
||||||
|
if sent_at is None:
|
||||||
|
sent_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
resolved_user = user or getattr(sub, "user", None)
|
||||||
|
lang = getattr(resolved_user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
||||||
|
user_id = int(getattr(sub, "user_id", 0) or 0)
|
||||||
|
user_name = getattr(resolved_user, "first_name", None) or f"User {user_id}"
|
||||||
|
final_end_date_text = end_date_text
|
||||||
|
if final_end_date_text is None:
|
||||||
|
end_date = self._as_utc(getattr(sub, "end_date", None))
|
||||||
|
final_end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
|
||||||
|
|
||||||
|
kwargs = {"user_name": user_name, "end_date": final_end_date_text}
|
||||||
|
if stage.hours_before is not None:
|
||||||
|
kwargs["hours"] = stage.hours_before
|
||||||
|
|
||||||
|
message_text = self.i18n.gettext(lang, stage.message_key, **kwargs)
|
||||||
|
final_extra_text = str(extra_text or "").strip()
|
||||||
|
if final_extra_text:
|
||||||
|
message_text = f"{message_text}\n\n{final_extra_text}"
|
||||||
|
|
||||||
|
telegram_sent = await self._send_telegram(
|
||||||
|
session,
|
||||||
|
sub,
|
||||||
|
stage,
|
||||||
|
resolved_user,
|
||||||
|
lang=lang,
|
||||||
|
message_text=message_text,
|
||||||
|
markup=telegram_markup or get_subscribe_only_markup(lang, self.i18n),
|
||||||
|
sent_at=sent_at,
|
||||||
|
)
|
||||||
|
email_sent = await self._send_email(
|
||||||
|
session,
|
||||||
|
sub,
|
||||||
|
stage,
|
||||||
|
resolved_user,
|
||||||
|
lang=lang,
|
||||||
|
message_text=message_text,
|
||||||
|
end_date_text=final_end_date_text,
|
||||||
|
sent_at=sent_at,
|
||||||
|
)
|
||||||
|
return SubscriptionNotificationDelivery(
|
||||||
|
telegram_sent=telegram_sent,
|
||||||
|
email_sent=email_sent,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _send_telegram(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
sub: Subscription,
|
||||||
|
stage: SubscriptionNotificationStage,
|
||||||
|
user: Optional[User],
|
||||||
|
*,
|
||||||
|
lang: str,
|
||||||
|
message_text: str,
|
||||||
|
markup: Optional[InlineKeyboardMarkup],
|
||||||
|
sent_at: datetime,
|
||||||
|
) -> bool:
|
||||||
|
chat_id = self._telegram_chat_id(user, getattr(sub, "user_id", None))
|
||||||
|
if chat_id is None:
|
||||||
|
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 Exception:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to send subscription notification %s to Telegram user %s",
|
||||||
|
stage.key,
|
||||||
|
chat_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
await subscription_dal.record_subscription_notification(
|
||||||
|
session,
|
||||||
|
sub.subscription_id,
|
||||||
|
self._channel_key(stage.key, "telegram"),
|
||||||
|
sent_at=sent_at,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _send_email(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
sub: Subscription,
|
||||||
|
stage: SubscriptionNotificationStage,
|
||||||
|
user: Optional[User],
|
||||||
|
*,
|
||||||
|
lang: str,
|
||||||
|
message_text: str,
|
||||||
|
end_date_text: str,
|
||||||
|
sent_at: datetime,
|
||||||
|
) -> bool:
|
||||||
|
if not getattr(self.settings, "SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED", True):
|
||||||
|
return False
|
||||||
|
if not getattr(self.settings, "email_auth_configured", False):
|
||||||
|
return False
|
||||||
|
recipient = str(getattr(user, "email", "") or "").strip() if user else ""
|
||||||
|
if not recipient:
|
||||||
|
return False
|
||||||
|
if await self._already_sent(session, sub.subscription_id, stage.key, "email"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = render_subscription_lifecycle_notification(
|
||||||
|
self.settings,
|
||||||
|
language_code=lang,
|
||||||
|
notification_key=stage.key,
|
||||||
|
message_text=message_text,
|
||||||
|
end_date_text=end_date_text,
|
||||||
|
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
|
||||||
|
days_left=stage.days_left,
|
||||||
|
hours_before=stage.hours_before,
|
||||||
|
i18n=self.i18n,
|
||||||
|
)
|
||||||
|
email_service = self.email_service or EmailAuthService(self.settings, self.i18n)
|
||||||
|
await email_service.send_rendered_email(email=recipient, content=content)
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to send subscription notification %s to email %s",
|
||||||
|
stage.key,
|
||||||
|
recipient,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
await subscription_dal.record_subscription_notification(
|
||||||
|
session,
|
||||||
|
sub.subscription_id,
|
||||||
|
self._channel_key(stage.key, "email"),
|
||||||
|
sent_at=sent_at,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _already_sent(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
subscription_id: int,
|
||||||
|
stage_key: str,
|
||||||
|
channel: str,
|
||||||
|
) -> bool:
|
||||||
|
channel_key = self._channel_key(stage_key, channel)
|
||||||
|
if await subscription_dal.has_subscription_notification(
|
||||||
|
session,
|
||||||
|
subscription_id,
|
||||||
|
channel_key,
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Legacy rows were stored without a channel. Treat them as Telegram-only
|
||||||
|
# history so existing installs do not re-send old bot messages, while
|
||||||
|
# still allowing the newly introduced email channel to catch up.
|
||||||
|
return channel == "telegram" and await subscription_dal.has_subscription_notification(
|
||||||
|
session,
|
||||||
|
subscription_id,
|
||||||
|
stage_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _channel_key(stage_key: str, channel: str) -> str:
|
||||||
|
return f"{stage_key}:{channel}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _telegram_chat_id(user: Optional[User], fallback_user_id: Optional[int]) -> Optional[int]:
|
||||||
|
for candidate in (getattr(user, "telegram_id", None), fallback_user_id):
|
||||||
|
try:
|
||||||
|
chat_id = int(candidate or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if chat_id > 0:
|
||||||
|
return chat_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_utc(value: Optional[datetime]) -> Optional[datetime]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -15,6 +14,10 @@ from bot.infra.redis import redis_lock
|
|||||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
|
from bot.services.subscription_lifecycle_notifications import (
|
||||||
|
SubscriptionLifecycleNotificationService,
|
||||||
|
SubscriptionNotificationStage,
|
||||||
|
)
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import subscription_dal
|
from db.dal import subscription_dal
|
||||||
@@ -26,13 +29,6 @@ EXPIRED_NOTIFICATION_WINDOW = timedelta(hours=24)
|
|||||||
EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48)
|
EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SubscriptionNotificationStage:
|
|
||||||
key: str
|
|
||||||
message_key: str
|
|
||||||
hours_before: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionNotificationWorker:
|
class SubscriptionNotificationWorker:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -49,6 +45,11 @@ class SubscriptionNotificationWorker:
|
|||||||
self.i18n = i18n
|
self.i18n = i18n
|
||||||
self.panel_service = panel_service
|
self.panel_service = panel_service
|
||||||
self.subscription_service = subscription_service
|
self.subscription_service = subscription_service
|
||||||
|
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
|
||||||
|
settings,
|
||||||
|
bot,
|
||||||
|
i18n,
|
||||||
|
)
|
||||||
self._stopped = asyncio.Event()
|
self._stopped = asyncio.Event()
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
@@ -114,18 +115,10 @@ class SubscriptionNotificationWorker:
|
|||||||
stage = self.stage_for_subscription(sub, now)
|
stage = self.stage_for_subscription(sub, now)
|
||||||
if stage is None:
|
if stage is None:
|
||||||
continue
|
continue
|
||||||
if await subscription_dal.has_subscription_notification(
|
await self.lifecycle_notifications.send_stage(
|
||||||
session,
|
session,
|
||||||
sub.subscription_id,
|
sub,
|
||||||
stage.key,
|
stage,
|
||||||
):
|
|
||||||
continue
|
|
||||||
if not await self._send_expiry_notification(sub, stage):
|
|
||||||
continue
|
|
||||||
await subscription_dal.record_subscription_notification(
|
|
||||||
session,
|
|
||||||
sub.subscription_id,
|
|
||||||
stage.key,
|
|
||||||
sent_at=now,
|
sent_at=now,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -164,6 +157,7 @@ class SubscriptionNotificationWorker:
|
|||||||
return SubscriptionNotificationStage(
|
return SubscriptionNotificationStage(
|
||||||
key=f"before_{days_before}d",
|
key=f"before_{days_before}d",
|
||||||
message_key=message_key,
|
message_key=message_key,
|
||||||
|
days_left=days_before,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -175,6 +169,7 @@ class SubscriptionNotificationWorker:
|
|||||||
return SubscriptionNotificationStage(
|
return SubscriptionNotificationStage(
|
||||||
key="expired",
|
key="expired",
|
||||||
message_key="subscription_expired_notification",
|
message_key="subscription_expired_notification",
|
||||||
|
days_left=0,
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True)
|
getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True)
|
||||||
@@ -183,6 +178,7 @@ class SubscriptionNotificationWorker:
|
|||||||
return SubscriptionNotificationStage(
|
return SubscriptionNotificationStage(
|
||||||
key="expired_24h_after",
|
key="expired_24h_after",
|
||||||
message_key="subscription_expired_yesterday_notification",
|
message_key="subscription_expired_yesterday_notification",
|
||||||
|
days_left=0,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -257,38 +253,6 @@ class SubscriptionNotificationWorker:
|
|||||||
return None
|
return None
|
||||||
return data if isinstance(data, dict) else None
|
return data if isinstance(data, dict) else None
|
||||||
|
|
||||||
async def _send_expiry_notification(
|
|
||||||
self,
|
|
||||||
sub: Subscription,
|
|
||||||
stage: SubscriptionNotificationStage,
|
|
||||||
) -> bool:
|
|
||||||
user_id = int(getattr(sub, "user_id", 0) or 0)
|
|
||||||
if user_id <= 0:
|
|
||||||
return False
|
|
||||||
user = getattr(sub, "user", None)
|
|
||||||
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
|
||||||
user_name = getattr(user, "first_name", None) or f"User {user_id}"
|
|
||||||
end_date = self._as_utc(getattr(sub, "end_date", None))
|
|
||||||
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
|
|
||||||
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
|
|
||||||
kwargs = {"user_name": user_name, "end_date": end_date_text}
|
|
||||||
if stage.hours_before is not None:
|
|
||||||
kwargs["hours"] = stage.hours_before
|
|
||||||
try:
|
|
||||||
await self.bot.send_message(
|
|
||||||
user_id,
|
|
||||||
translate(stage.message_key, **kwargs),
|
|
||||||
reply_markup=get_subscribe_only_markup(lang, self.i18n),
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
logging.exception(
|
|
||||||
"Failed to send subscription notification %s to user %s",
|
|
||||||
stage.key,
|
|
||||||
user_id,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _send_trial_traffic_depleted(
|
async def _send_trial_traffic_depleted(
|
||||||
self,
|
self,
|
||||||
sub: Subscription,
|
sub: Subscription,
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
||||||
|
SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
||||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
|
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
|
||||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
|
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
|
||||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3)
|
SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3)
|
||||||
|
|||||||
@@ -385,6 +385,7 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
|||||||
| `REFERRAL_BONUS_DAYS_1_MONTH`, `REFERRAL_BONUS_DAYS_3_MONTHS`, `REFERRAL_BONUS_DAYS_6_MONTHS`, `REFERRAL_BONUS_DAYS_12_MONTHS` | Legacy-бонусы пригласившему. |
|
| `REFERRAL_BONUS_DAYS_1_MONTH`, `REFERRAL_BONUS_DAYS_3_MONTHS`, `REFERRAL_BONUS_DAYS_6_MONTHS`, `REFERRAL_BONUS_DAYS_12_MONTHS` | Legacy-бонусы пригласившему. |
|
||||||
| `REFEREE_BONUS_DAYS_1_MONTH`, `REFEREE_BONUS_DAYS_3_MONTHS`, `REFEREE_BONUS_DAYS_6_MONTHS`, `REFEREE_BONUS_DAYS_12_MONTHS` | Legacy-бонусы приглашенному. |
|
| `REFEREE_BONUS_DAYS_1_MONTH`, `REFEREE_BONUS_DAYS_3_MONTHS`, `REFEREE_BONUS_DAYS_6_MONTHS`, `REFEREE_BONUS_DAYS_12_MONTHS` | Legacy-бонусы приглашенному. |
|
||||||
| `SUBSCRIPTION_NOTIFICATIONS_ENABLED` | Включает напоминания о подписке. |
|
| `SUBSCRIPTION_NOTIFICATIONS_ENABLED` | Включает напоминания о подписке. |
|
||||||
|
| `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED` | Дублирует пользовательские уведомления жизненного цикла подписки на email, если SMTP настроен и у пользователя есть email. |
|
||||||
| `SUBSCRIPTION_NOTIFY_ON_EXPIRE` | Уведомлять в день окончания. |
|
| `SUBSCRIPTION_NOTIFY_ON_EXPIRE` | Уведомлять в день окончания. |
|
||||||
| `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE` | Уведомлять после окончания. |
|
| `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE` | Уведомлять после окончания. |
|
||||||
| `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` | За сколько дней предупреждать. |
|
| `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` | За сколько дней предупреждать. |
|
||||||
|
|||||||
@@ -105,4 +105,4 @@ docker compose logs -f backend
|
|||||||
- Код сразу устаревает: проверьте `EMAIL_CODE_TTL_SECONDS` и время на сервере.
|
- Код сразу устаревает: проверьте `EMAIL_CODE_TTL_SECONDS` и время на сервере.
|
||||||
- Пользователь получает `rate_limited`: подождите `EMAIL_CODE_RESEND_SECONDS` или проверьте brute-force настройки.
|
- Пользователь получает `rate_limited`: подождите `EMAIL_CODE_RESEND_SECONDS` или проверьте brute-force настройки.
|
||||||
|
|
||||||
Email-уведомления поддержки и платежей используют тот же SMTP-контур. Сценарий поддержки описан в [разделе тикетов](support.md).
|
Email-уведомления поддержки, платежей и жизненного цикла подписки используют тот же SMTP-контур. Сценарий поддержки описан в [разделе тикетов](support.md), сводка по каналам - в разделе [уведомления](notifications.md).
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Minishop отправляет уведомления в Telegram и на email. Telegram-канал означает личные сообщения пользователю, сообщения администраторам из `ADMIN_IDS` или сообщения в `LOG_CHAT_ID` - зависит от события. Email работает только при настроенном SMTP и наличии email у получателя.
|
Minishop отправляет уведомления в Telegram и на email. Telegram-канал означает личные сообщения пользователю, сообщения администраторам из `ADMIN_IDS` или сообщения в `LOG_CHAT_ID` - зависит от события. Email работает только при настроенном SMTP и наличии email у получателя.
|
||||||
|
|
||||||
|
Для уведомлений жизненного цикла подписки есть отдельный флаг `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED`. Если он включен, пользовательские уведомления об окончании подписки отправляются в Telegram при наличии привязанного Telegram-аккаунта и на email при наличии привязанной почты.
|
||||||
|
|
||||||
## Сводная таблица
|
## Сводная таблица
|
||||||
|
|
||||||
| Событие | Получатель | Telegram | Email | Условия и ограничения |
|
| Событие | Получатель | Telegram | Email | Условия и ограничения |
|
||||||
@@ -13,13 +15,13 @@ Minishop отправляет уведомления в Telegram и на email.
|
|||||||
| Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Email отправляется, если SMTP настроен и у пользователя есть email. |
|
| Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||||
| Успешная покупка HWID-устройств | Пользователь | ✓ | - | Отправляется после оплаты `hwid_devices` или `hwid_devices_renewal`. |
|
| Успешная покупка HWID-устройств | Пользователь | ✓ | - | Отправляется после оплаты `hwid_devices` или `hwid_devices_renewal`. |
|
||||||
| Ошибка оплаты по webhook провайдера | Пользователь | ✓ | - | Отправляется, когда платежный провайдер сообщает о неуспешном платеже. |
|
| Ошибка оплаты по webhook провайдера | Пользователь | ✓ | - | Отправляется, когда платежный провайдер сообщает о неуспешном платеже. |
|
||||||
| Напоминание за 3 дня до окончания подписки | Пользователь | ✓ | ✓ | Telegram отправляет Remnawave webhook или локальный worker. Email отправляет только webhook `user.expires_in_72_hours`. |
|
| Напоминание за 3 дня до окончания подписки | Пользователь | ✓ | ✓ | Управляется `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` и `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED`. |
|
||||||
| Напоминание за 2 дня до окончания подписки | Пользователь | ✓ | - | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 2`. |
|
| Напоминание за 2 дня до окончания подписки | Пользователь | ✓ | ✓ | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 2`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||||
| Предупреждение о списании автопродления за 48 часов | Пользователь | ✓ | - | Отправляется вместо обычного 48-часового напоминания для YooKassa-подписок с включенным автопродлением. |
|
| Предупреждение о списании автопродления за 48 часов | Пользователь | ✓ | ✓ | Отправляется вместо обычного 48-часового напоминания для YooKassa-подписок с включенным автопродлением. |
|
||||||
| Напоминание за 1 день до окончания подписки | Пользователь | ✓ | - | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 1`. Для YooKassa auto-renew 24-часовой webhook сначала пытается списать продление и может не отправить напоминание. |
|
| Напоминание за 1 день до окончания подписки | Пользователь | ✓ | ✓ | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 1`. Для YooKassa auto-renew 24-часовой webhook сначала пытается списать продление и может не отправить напоминание. |
|
||||||
| Напоминание за несколько часов до окончания подписки | Пользователь | ✓ | - | Отправляется локальным worker, если `SUBSCRIPTION_NOTIFY_HOURS_BEFORE` задан от 1 до 23. |
|
| Напоминание за несколько часов до окончания подписки | Пользователь | ✓ | ✓ | Отправляется локальным worker, если `SUBSCRIPTION_NOTIFY_HOURS_BEFORE` задан от 1 до 23. |
|
||||||
| Уведомление в день окончания подписки | Пользователь | ✓ | - | Управляется `SUBSCRIPTION_NOTIFY_ON_EXPIRE`. |
|
| Уведомление в день окончания подписки | Пользователь | ✓ | ✓ | Управляется `SUBSCRIPTION_NOTIFY_ON_EXPIRE`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||||
| Уведомление через сутки после окончания подписки | Пользователь | ✓ | - | Управляется `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE`. |
|
| Уведомление через сутки после окончания подписки | Пользователь | ✓ | ✓ | Управляется `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||||
| Исчерпан трафик пробного периода | Пользователь | ✓ | - | Отправляется локальным worker для trial-подписок. |
|
| Исчерпан трафик пробного периода | Пользователь | ✓ | - | Отправляется локальным worker для trial-подписок. |
|
||||||
| Ответ администратора в тикете поддержки | Пользователь | ✓ | ✓ | Telegram отправляется пользователям с Telegram-аккаунтом, email - пользователям с привязанным email. |
|
| Ответ администратора в тикете поддержки | Пользователь | ✓ | ✓ | Telegram отправляется пользователям с Telegram-аккаунтом, email - пользователям с привязанным email. |
|
||||||
| Закрытие тикета поддержки | Пользователь | ✓ | ✓ | Telegram отправляется пользователям с Telegram-аккаунтом, email - пользователям с привязанным email. |
|
| Закрытие тикета поддержки | Пользователь | ✓ | ✓ | Telegram отправляется пользователям с Telegram-аккаунтом, email - пользователям с привязанным email. |
|
||||||
@@ -42,14 +44,18 @@ Minishop отправляет уведомления в Telegram и на email.
|
|||||||
|
|
||||||
## Важно про окончание подписки
|
## Важно про окончание подписки
|
||||||
|
|
||||||
Если пользователь привязал email к Telegram-аккаунту, уведомления об окончании подписки не дублируются в почту полностью. Сейчас email получает только напоминание за 3 дня до окончания, и только когда оно пришло из Remnawave Panel webhook `user.expires_in_72_hours`. Остальные стадии - 48 часов, 24 часа, почасовое предупреждение, день окончания и уведомление через сутки после окончания - отправляются только в Telegram.
|
Если пользователь привязал email к Telegram-аккаунту, уведомления об окончании подписки дублируются в оба канала: Telegram и email. Если привязан только Telegram - уйдет только Telegram. Если есть только email - уйдет только email.
|
||||||
|
|
||||||
Если Remnawave webhook не настроен или используется только локальный `SubscriptionNotificationWorker`, даже напоминание за 3 дня уйдет только в Telegram.
|
Email-доставка требует одновременно:
|
||||||
|
|
||||||
## Рекомендации по синхронизации каналов
|
- `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`;
|
||||||
|
- настроенный SMTP-контур;
|
||||||
|
- email у пользователя.
|
||||||
|
|
||||||
- Для пользовательских уведомлений о подписке лучше выбрать единую политику: либо email дублирует все важные стадии, либо email используется только для мягкого раннего напоминания за 3 дня. Текущее поведение ближе ко второму варианту.
|
## Синхронизация каналов
|
||||||
- Если нужно полное дублирование, стоит вынести отправку lifecycle-уведомлений подписки в общий сервис и вызывать его и из Remnawave webhook, и из локального worker. Тогда одна и та же стадия будет одинаково обрабатываться для Telegram и email.
|
|
||||||
- Дедупликацию лучше вести отдельно по каналу, например `before_3d:telegram` и `before_3d:email`, чтобы сбой Telegram не блокировал email и наоборот.
|
Remnawave webhook и локальный `SubscriptionNotificationWorker` используют общий сервис отправки и общую таблицу дедупликации `subscription_notifications`.
|
||||||
- Для email-канала стоит добавить отдельный флаг вроде `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED` и, при необходимости, настройки по стадиям: за 3 дня, за 1 день, в день окончания, после окончания.
|
|
||||||
- Если одновременно включены Remnawave webhook и локальный worker, стоит синхронизировать их через одну таблицу отправленных уведомлений. Сейчас worker пишет историю `subscription_notifications`, а webhook-уведомления обрабатываются отдельно, поэтому при близких расписаниях возможны повторы в Telegram.
|
Дедупликация ведется отдельно по каналам: например, `before_3d:telegram` и `before_3d:email`. Благодаря этому сбой одного канала не блокирует второй, а повторное событие из webhook или worker не отправляет уже доставленное уведомление повторно.
|
||||||
|
|
||||||
|
Старые записи без канала, например `before_3d`, считаются Telegram-историей. Они блокируют повторное Telegram-сообщение, но не мешают отправить email после обновления.
|
||||||
|
|||||||
@@ -623,6 +623,15 @@
|
|||||||
"email_subscription_expiring_note": "If you've already renewed or use auto-renewal, you can ignore this email.",
|
"email_subscription_expiring_note": "If you've already renewed or use auto-renewal, you can ignore this email.",
|
||||||
"email_subscription_expiring_text": "{heading}.\nActive until: {end_date}.",
|
"email_subscription_expiring_text": "{heading}.\nActive until: {end_date}.",
|
||||||
"email_subscription_expiring_text_renew": "Renew: {url}",
|
"email_subscription_expiring_text_renew": "Renew: {url}",
|
||||||
|
"email_subscription_lifecycle_subject_before_days": "{days} days left on your subscription",
|
||||||
|
"email_subscription_lifecycle_subject_before_hours": "{hours} hours left on your subscription",
|
||||||
|
"email_subscription_lifecycle_subject_expired": "Your subscription has expired",
|
||||||
|
"email_subscription_lifecycle_subject_expired_after": "Your subscription expired yesterday",
|
||||||
|
"email_subscription_lifecycle_subject_autorenew": "Auto-renewal runs tomorrow",
|
||||||
|
"email_subscription_lifecycle_intro": "This notification is mirrored from Telegram so you do not miss an important subscription event.",
|
||||||
|
"email_subscription_lifecycle_row_end_date": "Active until",
|
||||||
|
"email_subscription_lifecycle_cta": "Open dashboard",
|
||||||
|
"email_subscription_lifecycle_text_renew": "Dashboard: {url}",
|
||||||
"wa_loading": "Loading...",
|
"wa_loading": "Loading...",
|
||||||
"wa_back": "Back",
|
"wa_back": "Back",
|
||||||
"wa_next": "Next",
|
"wa_next": "Next",
|
||||||
@@ -1558,6 +1567,8 @@
|
|||||||
"admin_settings_field_referral_bonus_days_referee_6_months_label": "Referral Bonus Days Referee 6 Months",
|
"admin_settings_field_referral_bonus_days_referee_6_months_label": "Referral Bonus Days Referee 6 Months",
|
||||||
"admin_settings_field_referral_bonus_days_referee_12_months_label": "Referral Bonus Days Referee 12 Months",
|
"admin_settings_field_referral_bonus_days_referee_12_months_label": "Referral Bonus Days Referee 12 Months",
|
||||||
"admin_settings_field_subscription_notifications_enabled_label": "Subscription Notifications Enabled",
|
"admin_settings_field_subscription_notifications_enabled_label": "Subscription Notifications Enabled",
|
||||||
|
"admin_settings_field_subscription_email_notifications_enabled_label": "Subscription email notifications",
|
||||||
|
"admin_settings_field_subscription_email_notifications_enabled_description": "When enabled, subscription lifecycle notifications are mirrored to linked user email addresses.",
|
||||||
"admin_settings_field_subscription_notify_on_expire_label": "Subscription Notify On Expire",
|
"admin_settings_field_subscription_notify_on_expire_label": "Subscription Notify On Expire",
|
||||||
"admin_settings_field_subscription_notify_after_expire_label": "Subscription Notify After Expire",
|
"admin_settings_field_subscription_notify_after_expire_label": "Subscription Notify After Expire",
|
||||||
"admin_settings_field_subscription_notify_days_before_label": "Subscription Notify Days Before",
|
"admin_settings_field_subscription_notify_days_before_label": "Subscription Notify Days Before",
|
||||||
|
|||||||
@@ -623,6 +623,15 @@
|
|||||||
"email_subscription_expiring_note": "Если вы уже продлили или включили автопродление — просто проигнорируйте это письмо.",
|
"email_subscription_expiring_note": "Если вы уже продлили или включили автопродление — просто проигнорируйте это письмо.",
|
||||||
"email_subscription_expiring_text": "{heading}.\nДействует до: {end_date}.",
|
"email_subscription_expiring_text": "{heading}.\nДействует до: {end_date}.",
|
||||||
"email_subscription_expiring_text_renew": "Продлить: {url}",
|
"email_subscription_expiring_text_renew": "Продлить: {url}",
|
||||||
|
"email_subscription_lifecycle_subject_before_days": "До конца подписки осталось {days} дн.",
|
||||||
|
"email_subscription_lifecycle_subject_before_hours": "До конца подписки осталось {hours} ч.",
|
||||||
|
"email_subscription_lifecycle_subject_expired": "Подписка закончилась",
|
||||||
|
"email_subscription_lifecycle_subject_expired_after": "Подписка закончилась сутки назад",
|
||||||
|
"email_subscription_lifecycle_subject_autorenew": "Завтра автопродление подписки",
|
||||||
|
"email_subscription_lifecycle_intro": "Это уведомление продублировано из Telegram, чтобы вы не пропустили важное событие по подписке.",
|
||||||
|
"email_subscription_lifecycle_row_end_date": "Действует до",
|
||||||
|
"email_subscription_lifecycle_cta": "Открыть кабинет",
|
||||||
|
"email_subscription_lifecycle_text_renew": "Кабинет: {url}",
|
||||||
"wa_loading": "Загрузка...",
|
"wa_loading": "Загрузка...",
|
||||||
"wa_back": "Назад",
|
"wa_back": "Назад",
|
||||||
"wa_next": "Далее",
|
"wa_next": "Далее",
|
||||||
@@ -1558,6 +1567,8 @@
|
|||||||
"admin_settings_field_referral_bonus_days_referee_6_months_label": "Бонус приглашённому: 6 мес.",
|
"admin_settings_field_referral_bonus_days_referee_6_months_label": "Бонус приглашённому: 6 мес.",
|
||||||
"admin_settings_field_referral_bonus_days_referee_12_months_label": "Бонус приглашённому: 12 мес.",
|
"admin_settings_field_referral_bonus_days_referee_12_months_label": "Бонус приглашённому: 12 мес.",
|
||||||
"admin_settings_field_subscription_notifications_enabled_label": "Включены уведомления о подписке",
|
"admin_settings_field_subscription_notifications_enabled_label": "Включены уведомления о подписке",
|
||||||
|
"admin_settings_field_subscription_email_notifications_enabled_label": "Email-уведомления о подписке",
|
||||||
|
"admin_settings_field_subscription_email_notifications_enabled_description": "Если включено, уведомления жизненного цикла подписки дублируются на email пользователям с привязанной почтой.",
|
||||||
"admin_settings_field_subscription_notify_on_expire_label": "Уведомлять об истечении",
|
"admin_settings_field_subscription_notify_on_expire_label": "Уведомлять об истечении",
|
||||||
"admin_settings_field_subscription_notify_after_expire_label": "Уведомлять после истечения",
|
"admin_settings_field_subscription_notify_after_expire_label": "Уведомлять после истечения",
|
||||||
"admin_settings_field_subscription_notify_days_before_label": "За сколько дней предупреждать",
|
"admin_settings_field_subscription_notify_days_before_label": "За сколько дней предупреждать",
|
||||||
|
|||||||
@@ -282,3 +282,4 @@ class SettingsTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(settings.SUBSCRIPTION_NOTIFY_HOURS_BEFORE, 3)
|
self.assertEqual(settings.SUBSCRIPTION_NOTIFY_HOURS_BEFORE, 3)
|
||||||
self.assertEqual(settings.SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS, 300)
|
self.assertEqual(settings.SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS, 300)
|
||||||
|
self.assertTrue(settings.SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED)
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from bot.services import subscription_lifecycle_notifications as lifecycle
|
||||||
|
from bot.services.subscription_lifecycle_notifications import (
|
||||||
|
SubscriptionLifecycleNotificationService,
|
||||||
|
SubscriptionNotificationStage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeI18n:
|
||||||
|
def gettext(self, lang_code, key, **kwargs):
|
||||||
|
messages = {
|
||||||
|
"subscription_72h_notification": "Hi {user_name}, expires on {end_date}",
|
||||||
|
"email_subscription_lifecycle_subject_before_days": "{days} days left",
|
||||||
|
"email_subscription_lifecycle_subject_before_hours": "{hours} hours left",
|
||||||
|
"email_subscription_lifecycle_subject_expired": "Expired",
|
||||||
|
"email_subscription_lifecycle_subject_expired_after": "Expired yesterday",
|
||||||
|
"email_subscription_lifecycle_subject_autorenew": "Auto-renewal tomorrow",
|
||||||
|
"email_subscription_lifecycle_intro": "Subscription notice",
|
||||||
|
"email_subscription_lifecycle_row_end_date": "Active until",
|
||||||
|
"email_subscription_lifecycle_cta": "Open dashboard",
|
||||||
|
"email_subscription_lifecycle_text_renew": "Dashboard: {url}",
|
||||||
|
"email_footer_auto": "Sent by {brand}",
|
||||||
|
}
|
||||||
|
return messages.get(key, key).format(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBot:
|
||||||
|
def __init__(self):
|
||||||
|
self.messages = []
|
||||||
|
|
||||||
|
async def send_message(self, chat_id, text, reply_markup=None):
|
||||||
|
self.messages.append(
|
||||||
|
{
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": text,
|
||||||
|
"reply_markup": reply_markup,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEmailService:
|
||||||
|
def __init__(self):
|
||||||
|
self.messages = []
|
||||||
|
|
||||||
|
async def send_rendered_email(self, *, email, content):
|
||||||
|
self.messages.append({"email": email, "content": content})
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**overrides):
|
||||||
|
return SimpleNamespace(
|
||||||
|
DEFAULT_LANGUAGE="ru",
|
||||||
|
SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True,
|
||||||
|
SUBSCRIPTION_MINI_APP_URL="https://app.example.test/",
|
||||||
|
WEBAPP_PRIMARY_COLOR="#00fe7a",
|
||||||
|
WEBAPP_TITLE="Minishop",
|
||||||
|
WEBAPP_LOGO_USE_EMOJI=False,
|
||||||
|
WEBAPP_LOGO_URL="",
|
||||||
|
email_auth_configured=True,
|
||||||
|
**overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _subscription():
|
||||||
|
return SimpleNamespace(
|
||||||
|
subscription_id=42,
|
||||||
|
user_id=123,
|
||||||
|
end_date=datetime(2026, 6, 1, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _user(**overrides):
|
||||||
|
return SimpleNamespace(
|
||||||
|
user_id=123,
|
||||||
|
telegram_id=555,
|
||||||
|
email="user@example.test",
|
||||||
|
language_code="ru",
|
||||||
|
first_name="Ada",
|
||||||
|
**overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_stage_records_telegram_and_email_channel_keys(monkeypatch):
|
||||||
|
recorded = []
|
||||||
|
|
||||||
|
async def fake_has(session, subscription_id, notification_key):
|
||||||
|
return notification_key in recorded
|
||||||
|
|
||||||
|
async def fake_record(session, subscription_id, notification_key, *, sent_at=None):
|
||||||
|
recorded.append(notification_key)
|
||||||
|
|
||||||
|
monkeypatch.setattr(lifecycle.subscription_dal, "has_subscription_notification", fake_has)
|
||||||
|
monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record)
|
||||||
|
|
||||||
|
bot = FakeBot()
|
||||||
|
email_service = FakeEmailService()
|
||||||
|
service = SubscriptionLifecycleNotificationService(
|
||||||
|
_settings(),
|
||||||
|
bot,
|
||||||
|
FakeI18n(),
|
||||||
|
email_service=email_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
return await service.send_stage(
|
||||||
|
object(),
|
||||||
|
_subscription(),
|
||||||
|
SubscriptionNotificationStage(
|
||||||
|
key="before_3d",
|
||||||
|
message_key="subscription_72h_notification",
|
||||||
|
days_left=3,
|
||||||
|
),
|
||||||
|
user=_user(),
|
||||||
|
telegram_markup="markup",
|
||||||
|
)
|
||||||
|
|
||||||
|
delivery = asyncio.run(run())
|
||||||
|
|
||||||
|
assert delivery.telegram_sent is True
|
||||||
|
assert delivery.email_sent is True
|
||||||
|
assert bot.messages == [
|
||||||
|
{
|
||||||
|
"chat_id": 555,
|
||||||
|
"text": "Hi Ada, expires on 2026-06-01",
|
||||||
|
"reply_markup": "markup",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert email_service.messages[0]["email"] == "user@example.test"
|
||||||
|
assert recorded == ["before_3d:telegram", "before_3d:email"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_stage_key_suppresses_only_telegram(monkeypatch):
|
||||||
|
recorded = ["before_3d"]
|
||||||
|
|
||||||
|
async def fake_has(session, subscription_id, notification_key):
|
||||||
|
return notification_key in recorded
|
||||||
|
|
||||||
|
async def fake_record(session, subscription_id, notification_key, *, sent_at=None):
|
||||||
|
recorded.append(notification_key)
|
||||||
|
|
||||||
|
monkeypatch.setattr(lifecycle.subscription_dal, "has_subscription_notification", fake_has)
|
||||||
|
monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record)
|
||||||
|
|
||||||
|
bot = FakeBot()
|
||||||
|
email_service = FakeEmailService()
|
||||||
|
service = SubscriptionLifecycleNotificationService(
|
||||||
|
_settings(),
|
||||||
|
bot,
|
||||||
|
FakeI18n(),
|
||||||
|
email_service=email_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
return await service.send_stage(
|
||||||
|
object(),
|
||||||
|
_subscription(),
|
||||||
|
SubscriptionNotificationStage(
|
||||||
|
key="before_3d",
|
||||||
|
message_key="subscription_72h_notification",
|
||||||
|
days_left=3,
|
||||||
|
),
|
||||||
|
user=_user(),
|
||||||
|
telegram_markup="markup",
|
||||||
|
)
|
||||||
|
|
||||||
|
delivery = asyncio.run(run())
|
||||||
|
|
||||||
|
assert delivery.telegram_sent is False
|
||||||
|
assert delivery.email_sent is True
|
||||||
|
assert bot.messages == []
|
||||||
|
assert email_service.messages[0]["email"] == "user@example.test"
|
||||||
|
assert recorded == ["before_3d", "before_3d:email"]
|
||||||
Reference in New Issue
Block a user