refactor: project architecture refactor, container splitting
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiohttp import web
|
||||
from sqlalchemy.orm import 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 config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
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"),
|
||||
}
|
||||
|
||||
|
||||
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._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)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user_id, _(message_key, **kwargs), reply_markup=reply_markup
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to send notification to %s", user_id)
|
||||
|
||||
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
|
||||
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 ""
|
||||
|
||||
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||
|
||||
if event_name in EVENT_MAP:
|
||||
days_left, msg_key = EVENT_MAP[event_name]
|
||||
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 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
|
||||
)
|
||||
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,
|
||||
)
|
||||
if sub and sub.auto_renew_enabled and 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,
|
||||
)
|
||||
return
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
msg_key,
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
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],
|
||||
)
|
||||
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],
|
||||
)
|
||||
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=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
|
||||
async def _send_subscription_expiring_email(
|
||||
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,
|
||||
)
|
||||
email_service = EmailAuthService(self.settings)
|
||||
await email_service.send_rendered_email(email=recipient, content=content)
|
||||
except Exception:
|
||||
logging.exception("Failed to send subscription-expiring email to %s", recipient)
|
||||
|
||||
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}:"
|
||||
f"{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)
|
||||
Reference in New Issue
Block a user