490 lines
19 KiB
Python
490 lines
19 KiB
Python
import asyncio
|
|
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 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 (
|
|
get_autorenew_cancel_keyboard,
|
|
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 subscription_dal, tariff_dal, user_dal
|
|
from db.models import Subscription, User
|
|
|
|
from .panel_api_service import PanelApiService
|
|
|
|
EVENT_MAP = {
|
|
"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,
|
|
),
|
|
}
|
|
|
|
|
|
class PanelWebhookService:
|
|
# Cap parallel background event handlers so an expiry burst from the panel
|
|
# cannot exhaust the DB pool or the YooKassa client.
|
|
_MAX_CONCURRENT_EVENTS = 50
|
|
|
|
def __init__(
|
|
self,
|
|
bot: Bot,
|
|
settings: Settings,
|
|
i18n: JsonI18n,
|
|
async_session_factory: sessionmaker,
|
|
panel_service: PanelApiService,
|
|
):
|
|
self.bot = bot
|
|
self.settings = settings
|
|
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(
|
|
"PANEL_WEBHOOK_SECRET is not configured. Panel webhooks will be rejected."
|
|
)
|
|
|
|
async def _send_message(
|
|
self,
|
|
user_id: int,
|
|
lang: str,
|
|
message_key: str,
|
|
reply_markup: InlineKeyboardMarkup | None = None,
|
|
**kwargs,
|
|
):
|
|
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
|
|
extra_text = str(kwargs.pop("extra_text", "") or "").strip()
|
|
try:
|
|
text = _(message_key, **kwargs)
|
|
if extra_text:
|
|
text = f"{text}\n\n{extra_text}"
|
|
await self.bot.send_message(user_id, text, reply_markup=reply_markup)
|
|
except Exception:
|
|
logging.exception("Failed to send notification to %s", user_id)
|
|
|
|
async def _hwid_renewal_note(self, internal_user_id: int, lang: str) -> str:
|
|
try:
|
|
from db.dal import subscription_dal
|
|
|
|
async with self.async_session_factory() as session:
|
|
sub = await subscription_dal.get_active_subscription_by_user_id(
|
|
session, internal_user_id
|
|
)
|
|
if not sub:
|
|
return ""
|
|
summary = await tariff_dal.get_hwid_device_entitlement_summary(
|
|
session,
|
|
subscription_id=sub.subscription_id,
|
|
)
|
|
count = int(summary.get("active_devices") or sub.extra_hwid_devices or 0)
|
|
if count <= 0:
|
|
return ""
|
|
active_until = summary.get("active_until") or sub.end_date
|
|
date_text = active_until.strftime("%Y-%m-%d") if active_until else ""
|
|
except Exception:
|
|
logging.exception("Failed to build HWID renewal note for user %s", internal_user_id)
|
|
return ""
|
|
return self.i18n.gettext(
|
|
lang,
|
|
"subscription_hwid_renewal_reminder",
|
|
count=count,
|
|
date=date_text,
|
|
)
|
|
|
|
async def handle_event(self, event_name: str, user_payload: dict):
|
|
if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED:
|
|
return
|
|
|
|
async with self.async_session_factory() as session:
|
|
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
|
|
)
|
|
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)
|
|
end_date_text = self._payload_expire_date(user_payload)
|
|
|
|
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 (
|
|
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",
|
|
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 (
|
|
active_sub
|
|
and active_sub.auto_renew_enabled
|
|
and active_sub.provider == "yookassa"
|
|
):
|
|
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
|
|
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.lifecycle_notifications.send_stage(
|
|
session,
|
|
sub,
|
|
stage,
|
|
user=db_user,
|
|
telegram_markup=markup,
|
|
extra_text=hwid_renewal_note,
|
|
end_date_text=end_date_text,
|
|
)
|
|
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
|
|
):
|
|
await self._send_message(
|
|
user_id,
|
|
lang,
|
|
"subscription_expired_yesterday_notification",
|
|
reply_markup=markup,
|
|
user_name=first_name,
|
|
end_date=self._payload_expire_date(user_payload),
|
|
)
|
|
|
|
async def _user_for_payload(
|
|
self,
|
|
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),
|
|
)
|
|
.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]
|
|
) -> web.Response:
|
|
if not self.settings.PANEL_WEBHOOK_SECRET:
|
|
return web.Response(status=401, text="unauthorized")
|
|
|
|
if not signature_header:
|
|
return web.Response(status=401, text="unauthorized")
|
|
|
|
expected_sig = hmac.new(
|
|
self.settings.PANEL_WEBHOOK_SECRET.encode(),
|
|
raw_body,
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
if not hmac.compare_digest(expected_sig, signature_header):
|
|
return web.Response(status=401, text="unauthorized")
|
|
|
|
try:
|
|
payload = json.loads(raw_body.decode())
|
|
except Exception:
|
|
return web.Response(status=400, text="bad_request")
|
|
|
|
event_name = payload.get("name") or payload.get("event")
|
|
user_data = payload.get("payload") or payload.get("data", {})
|
|
if isinstance(user_data, dict) and "user" in user_data:
|
|
user_data = user_data.get("user") or user_data
|
|
|
|
telegram_id = user_data.get("telegramId") if isinstance(user_data, dict) else None
|
|
|
|
if not event_name:
|
|
return web.Response(status=200, text="ok_no_event")
|
|
|
|
logging.info(
|
|
"Panel webhook event received: %s; telegramId=%s",
|
|
event_name,
|
|
telegram_id if telegram_id is not None else "N/A",
|
|
)
|
|
|
|
queued = await enqueue_webhook_event(
|
|
self.settings,
|
|
"panel",
|
|
{"event": event_name, "user": user_data},
|
|
event_id=(
|
|
f"{event_name}:{telegram_id or user_data.get('uuid') or user_data.get('shortUuid')}"
|
|
),
|
|
)
|
|
if not queued:
|
|
asyncio.create_task(
|
|
self._run_event_in_background(event_name, user_data),
|
|
name=f"panel_event_{event_name}",
|
|
)
|
|
return web.Response(status=200, text="ok")
|
|
|
|
async def _run_event_in_background(self, event_name: str, user_payload: dict) -> None:
|
|
async with self._event_semaphore:
|
|
try:
|
|
await self.handle_event(event_name, user_payload)
|
|
except Exception:
|
|
logging.exception(
|
|
"Panel webhook background handler failed for event %s", event_name
|
|
)
|
|
|
|
|
|
async def panel_webhook_route(request: web.Request):
|
|
service: PanelWebhookService = request.app["panel_webhook_service"]
|
|
raw = await request.read()
|
|
signature_header = request.headers.get("X-Remnawave-Signature")
|
|
return await service.handle_webhook(raw, signature_header)
|