feat: mirror subscription lifecycle notifications
This commit is contained in:
@@ -396,6 +396,13 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"notifications",
|
||||
"Включены уведомления о подписке",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED",
|
||||
"bool",
|
||||
"notifications",
|
||||
"Дублировать уведомления о подписке на email",
|
||||
"Письма отправляются только пользователям с привязанным email и рабочим SMTP.",
|
||||
),
|
||||
SettingField(
|
||||
"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)
|
||||
|
||||
|
||||
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(
|
||||
settings: Settings,
|
||||
i18n: Optional[JsonI18n],
|
||||
|
||||
@@ -3,12 +3,15 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
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.keyboards.inline.user_keyboards import (
|
||||
@@ -16,17 +19,32 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
get_subscribe_only_markup,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.subscription_lifecycle_notifications import (
|
||||
SubscriptionLifecycleNotificationService,
|
||||
SubscriptionNotificationStage,
|
||||
)
|
||||
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
|
||||
|
||||
EVENT_MAP = {
|
||||
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
|
||||
"user.expires_in_48_hours": (2, "subscription_48h_notification"),
|
||||
"user.expires_in_24_hours": (1, "subscription_24h_notification"),
|
||||
"user.expires_in_72_hours": SubscriptionNotificationStage(
|
||||
key="before_3d",
|
||||
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.async_session_factory = async_session_factory
|
||||
self.panel_service = panel_service
|
||||
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
|
||||
settings,
|
||||
bot,
|
||||
i18n,
|
||||
)
|
||||
self._event_semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_EVENTS)
|
||||
if not self.settings.PANEL_WEBHOOK_SECRET:
|
||||
logging.error(
|
||||
@@ -102,113 +125,182 @@ class PanelWebhookService:
|
||||
)
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
|
||||
if not db_user:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
internal_user_id = db_user.user_id if db_user else user_id
|
||||
db_user = await self._user_for_payload(session, user_payload)
|
||||
sub = await self._subscription_for_payload(session, user_payload, db_user)
|
||||
telegram_id = self._payload_telegram_id(user_payload)
|
||||
internal_user_id = (
|
||||
int(db_user.user_id)
|
||||
if db_user
|
||||
else int(getattr(sub, "user_id", 0) or telegram_id or 0)
|
||||
)
|
||||
lang = (
|
||||
db_user.language_code
|
||||
if db_user and db_user.language_code
|
||||
else self.settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
||||
user_email = (db_user.email or "").strip() if db_user else ""
|
||||
if not sub:
|
||||
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:
|
||||
days_left, msg_key = EVENT_MAP[event_name]
|
||||
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
|
||||
if days_left == 1:
|
||||
# Trigger auto-renew via SubscriptionService (wired in at factory)
|
||||
try:
|
||||
subscription_service = getattr(self, "subscription_service", None)
|
||||
if subscription_service:
|
||||
async with self.async_session_factory() as session:
|
||||
from db.dal import subscription_dal
|
||||
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, internal_user_id
|
||||
)
|
||||
if sub and sub.auto_renew_enabled and sub.provider == "yookassa":
|
||||
try:
|
||||
ok = await subscription_service.charge_subscription_renewal(
|
||||
session, sub
|
||||
if event_name in EVENT_MAP:
|
||||
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)
|
||||
if days_left == 1:
|
||||
# Trigger auto-renew via SubscriptionService (wired in at factory)
|
||||
try:
|
||||
subscription_service = getattr(self, "subscription_service", None)
|
||||
if subscription_service:
|
||||
async with self.async_session_factory() as renewal_session:
|
||||
active_sub = (
|
||||
await subscription_dal.get_active_subscription_by_user_id(
|
||||
renewal_session,
|
||||
internal_user_id,
|
||||
)
|
||||
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
|
||||
if ok:
|
||||
await session.commit()
|
||||
return
|
||||
else:
|
||||
await session.rollback()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("Auto-renew attempt (24h) failed")
|
||||
except Exception:
|
||||
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
||||
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
||||
# For 48h event, if auto-renew is enabled, show special notice with cancel button
|
||||
if days_left == 2:
|
||||
async with self.async_session_factory() as session:
|
||||
from db.dal import subscription_dal
|
||||
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, internal_user_id
|
||||
)
|
||||
if (
|
||||
active_sub
|
||||
and active_sub.auto_renew_enabled
|
||||
and active_sub.provider == "yookassa"
|
||||
):
|
||||
try:
|
||||
ok = await subscription_service.charge_subscription_renewal(
|
||||
renewal_session,
|
||||
active_sub,
|
||||
)
|
||||
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
|
||||
if ok:
|
||||
await renewal_session.commit()
|
||||
return
|
||||
await renewal_session.rollback()
|
||||
except Exception:
|
||||
await renewal_session.rollback()
|
||||
logging.exception("Auto-renew attempt (24h) failed")
|
||||
except Exception:
|
||||
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
||||
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
||||
# For 48h, auto-renew users get a cancel button instead.
|
||||
if days_left == 2:
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session,
|
||||
internal_user_id,
|
||||
)
|
||||
logging.info(
|
||||
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
|
||||
user_id,
|
||||
bool(sub),
|
||||
getattr(sub, "auto_renew_enabled", None) if sub else None,
|
||||
getattr(sub, "provider", None) if sub else None,
|
||||
internal_user_id,
|
||||
bool(active_sub),
|
||||
getattr(active_sub, "auto_renew_enabled", None) if active_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)
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
"autorenew_48h_charge_tomorrow_notice",
|
||||
reply_markup=cancel_kb,
|
||||
user_name=first_name,
|
||||
await self.lifecycle_notifications.send_stage(
|
||||
session,
|
||||
sub,
|
||||
SubscriptionNotificationStage(
|
||||
key="before_2d_autorenew",
|
||||
message_key="autorenew_48h_charge_tomorrow_notice",
|
||||
days_left=2,
|
||||
),
|
||||
user=db_user,
|
||||
telegram_markup=cancel_kb,
|
||||
extra_text=hwid_renewal_note,
|
||||
end_date_text=end_date_text,
|
||||
)
|
||||
await session.commit()
|
||||
return
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
msg_key,
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
extra_text=hwid_renewal_note,
|
||||
)
|
||||
if days_left == 3 and user_email:
|
||||
await self._send_subscription_expiring_email(
|
||||
recipient=user_email,
|
||||
lang=lang,
|
||||
days_left=days_left,
|
||||
end_date_text=user_payload.get("expireAt", "")[:10],
|
||||
await self.lifecycle_notifications.send_stage(
|
||||
session,
|
||||
sub,
|
||||
stage,
|
||||
user=db_user,
|
||||
telegram_markup=markup,
|
||||
extra_text=hwid_renewal_note,
|
||||
end_date_text=end_date_text,
|
||||
)
|
||||
elif event_name == "user.expired":
|
||||
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
"subscription_expired_notification",
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
await session.commit()
|
||||
elif event_name == "user.expired":
|
||||
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(
|
||||
user_id,
|
||||
lang,
|
||||
"subscription_expired_notification",
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=self._payload_expire_date(user_payload),
|
||||
)
|
||||
elif (
|
||||
event_name == "user.expired_24_hours_ago"
|
||||
and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE
|
||||
@@ -219,33 +311,113 @@ class PanelWebhookService:
|
||||
"subscription_expired_yesterday_notification",
|
||||
reply_markup=markup,
|
||||
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,
|
||||
*,
|
||||
recipient: str,
|
||||
lang: str,
|
||||
days_left: int,
|
||||
end_date_text: str,
|
||||
) -> None:
|
||||
"""Best-effort branded reminder; silently no-ops without SMTP config."""
|
||||
if not self.settings.email_auth_configured:
|
||||
return
|
||||
try:
|
||||
content = render_subscription_expiring(
|
||||
self.settings,
|
||||
language_code=lang,
|
||||
days_left=days_left,
|
||||
end_date_text=end_date_text,
|
||||
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
|
||||
i18n=self.i18n,
|
||||
session: AsyncSession,
|
||||
user_payload: dict,
|
||||
) -> Optional[User]:
|
||||
telegram_id = self._payload_telegram_id(user_payload)
|
||||
if telegram_id:
|
||||
user = await user_dal.get_user_by_telegram_id(session, telegram_id)
|
||||
if user:
|
||||
return user
|
||||
user = await user_dal.get_user_by_id(session, telegram_id)
|
||||
if user:
|
||||
return user
|
||||
|
||||
panel_uuid = self._payload_panel_uuid(user_payload)
|
||||
if panel_uuid:
|
||||
user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
|
||||
if user:
|
||||
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)
|
||||
await email_service.send_rendered_email(email=recipient, content=content)
|
||||
except Exception:
|
||||
logging.exception("Failed to send subscription-expiring email to %s", recipient)
|
||||
.options(selectinload(Subscription.user))
|
||||
)
|
||||
|
||||
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(
|
||||
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 logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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.middlewares.i18n import JsonI18n
|
||||
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 config.settings import Settings
|
||||
from db.dal import subscription_dal
|
||||
@@ -26,13 +29,6 @@ EXPIRED_NOTIFICATION_WINDOW = timedelta(hours=24)
|
||||
EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionNotificationStage:
|
||||
key: str
|
||||
message_key: str
|
||||
hours_before: Optional[int] = None
|
||||
|
||||
|
||||
class SubscriptionNotificationWorker:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -49,6 +45,11 @@ class SubscriptionNotificationWorker:
|
||||
self.i18n = i18n
|
||||
self.panel_service = panel_service
|
||||
self.subscription_service = subscription_service
|
||||
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
|
||||
settings,
|
||||
bot,
|
||||
i18n,
|
||||
)
|
||||
self._stopped = asyncio.Event()
|
||||
|
||||
async def run(self) -> None:
|
||||
@@ -114,18 +115,10 @@ class SubscriptionNotificationWorker:
|
||||
stage = self.stage_for_subscription(sub, now)
|
||||
if stage is None:
|
||||
continue
|
||||
if await subscription_dal.has_subscription_notification(
|
||||
await self.lifecycle_notifications.send_stage(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
stage.key,
|
||||
):
|
||||
continue
|
||||
if not await self._send_expiry_notification(sub, stage):
|
||||
continue
|
||||
await subscription_dal.record_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
stage.key,
|
||||
sub,
|
||||
stage,
|
||||
sent_at=now,
|
||||
)
|
||||
|
||||
@@ -164,6 +157,7 @@ class SubscriptionNotificationWorker:
|
||||
return SubscriptionNotificationStage(
|
||||
key=f"before_{days_before}d",
|
||||
message_key=message_key,
|
||||
days_left=days_before,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -175,6 +169,7 @@ class SubscriptionNotificationWorker:
|
||||
return SubscriptionNotificationStage(
|
||||
key="expired",
|
||||
message_key="subscription_expired_notification",
|
||||
days_left=0,
|
||||
)
|
||||
if (
|
||||
getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True)
|
||||
@@ -183,6 +178,7 @@ class SubscriptionNotificationWorker:
|
||||
return SubscriptionNotificationStage(
|
||||
key="expired_24h_after",
|
||||
message_key="subscription_expired_yesterday_notification",
|
||||
days_left=0,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -257,38 +253,6 @@ class SubscriptionNotificationWorker:
|
||||
return 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(
|
||||
self,
|
||||
sub: Subscription,
|
||||
|
||||
@@ -240,6 +240,7 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
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_AFTER_EXPIRE: bool = Field(default=True)
|
||||
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-бонусы пригласившему. |
|
||||
| `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_EMAIL_NOTIFICATIONS_ENABLED` | Дублирует пользовательские уведомления жизненного цикла подписки на email, если SMTP настроен и у пользователя есть email. |
|
||||
| `SUBSCRIPTION_NOTIFY_ON_EXPIRE` | Уведомлять в день окончания. |
|
||||
| `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE` | Уведомлять после окончания. |
|
||||
| `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` | За сколько дней предупреждать. |
|
||||
|
||||
@@ -105,4 +105,4 @@ docker compose logs -f backend
|
||||
- Код сразу устаревает: проверьте `EMAIL_CODE_TTL_SECONDS` и время на сервере.
|
||||
- Пользователь получает `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 у получателя.
|
||||
|
||||
Для уведомлений жизненного цикла подписки есть отдельный флаг `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED`. Если он включен, пользовательские уведомления об окончании подписки отправляются в Telegram при наличии привязанного Telegram-аккаунта и на email при наличии привязанной почты.
|
||||
|
||||
## Сводная таблица
|
||||
|
||||
| Событие | Получатель | Telegram | Email | Условия и ограничения |
|
||||
@@ -13,13 +15,13 @@ Minishop отправляет уведомления в Telegram и на email.
|
||||
| Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Успешная покупка HWID-устройств | Пользователь | ✓ | - | Отправляется после оплаты `hwid_devices` или `hwid_devices_renewal`. |
|
||||
| Ошибка оплаты по webhook провайдера | Пользователь | ✓ | - | Отправляется, когда платежный провайдер сообщает о неуспешном платеже. |
|
||||
| Напоминание за 3 дня до окончания подписки | Пользователь | ✓ | ✓ | Telegram отправляет Remnawave webhook или локальный worker. Email отправляет только webhook `user.expires_in_72_hours`. |
|
||||
| Напоминание за 2 дня до окончания подписки | Пользователь | ✓ | - | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 2`. |
|
||||
| Предупреждение о списании автопродления за 48 часов | Пользователь | ✓ | - | Отправляется вместо обычного 48-часового напоминания для YooKassa-подписок с включенным автопродлением. |
|
||||
| Напоминание за 1 день до окончания подписки | Пользователь | ✓ | - | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 1`. Для YooKassa auto-renew 24-часовой webhook сначала пытается списать продление и может не отправить напоминание. |
|
||||
| Напоминание за несколько часов до окончания подписки | Пользователь | ✓ | - | Отправляется локальным worker, если `SUBSCRIPTION_NOTIFY_HOURS_BEFORE` задан от 1 до 23. |
|
||||
| Уведомление в день окончания подписки | Пользователь | ✓ | - | Управляется `SUBSCRIPTION_NOTIFY_ON_EXPIRE`. |
|
||||
| Уведомление через сутки после окончания подписки | Пользователь | ✓ | - | Управляется `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE`. |
|
||||
| Напоминание за 3 дня до окончания подписки | Пользователь | ✓ | ✓ | Управляется `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` и `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED`. |
|
||||
| Напоминание за 2 дня до окончания подписки | Пользователь | ✓ | ✓ | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 2`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||
| Предупреждение о списании автопродления за 48 часов | Пользователь | ✓ | ✓ | Отправляется вместо обычного 48-часового напоминания для YooKassa-подписок с включенным автопродлением. |
|
||||
| Напоминание за 1 день до окончания подписки | Пользователь | ✓ | ✓ | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 1`. Для YooKassa auto-renew 24-часовой webhook сначала пытается списать продление и может не отправить напоминание. |
|
||||
| Напоминание за несколько часов до окончания подписки | Пользователь | ✓ | ✓ | Отправляется локальным worker, если `SUBSCRIPTION_NOTIFY_HOURS_BEFORE` задан от 1 до 23. |
|
||||
| Уведомление в день окончания подписки | Пользователь | ✓ | ✓ | Управляется `SUBSCRIPTION_NOTIFY_ON_EXPIRE`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||
| Уведомление через сутки после окончания подписки | Пользователь | ✓ | ✓ | Управляется `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||
| Исчерпан трафик пробного периода | Пользователь | ✓ | - | Отправляется локальным worker для trial-подписок. |
|
||||
| Ответ администратора в тикете поддержки | Пользователь | ✓ | ✓ | 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 и наоборот.
|
||||
- Для email-канала стоит добавить отдельный флаг вроде `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED` и, при необходимости, настройки по стадиям: за 3 дня, за 1 день, в день окончания, после окончания.
|
||||
- Если одновременно включены Remnawave webhook и локальный worker, стоит синхронизировать их через одну таблицу отправленных уведомлений. Сейчас worker пишет историю `subscription_notifications`, а webhook-уведомления обрабатываются отдельно, поэтому при близких расписаниях возможны повторы в Telegram.
|
||||
## Синхронизация каналов
|
||||
|
||||
Remnawave webhook и локальный `SubscriptionNotificationWorker` используют общий сервис отправки и общую таблицу дедупликации `subscription_notifications`.
|
||||
|
||||
Дедупликация ведется отдельно по каналам: например, `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_text": "{heading}.\nActive until: {end_date}.",
|
||||
"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_back": "Back",
|
||||
"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_12_months_label": "Referral Bonus Days Referee 12 Months",
|
||||
"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_after_expire_label": "Subscription Notify After Expire",
|
||||
"admin_settings_field_subscription_notify_days_before_label": "Subscription Notify Days Before",
|
||||
|
||||
@@ -623,6 +623,15 @@
|
||||
"email_subscription_expiring_note": "Если вы уже продлили или включили автопродление — просто проигнорируйте это письмо.",
|
||||
"email_subscription_expiring_text": "{heading}.\nДействует до: {end_date}.",
|
||||
"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_back": "Назад",
|
||||
"wa_next": "Далее",
|
||||
@@ -1558,6 +1567,8 @@
|
||||
"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_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_after_expire_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_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