- FastAPI REST API with JWT auth - aiogram 3 Telegram bot with admin middleware - APScheduler daily tasks (expiry, reminders, revoke, sync) - SQLAlchemy 2 async ORM with Alembic migrations - Jinja2 admin panel (Dashboard, Users, Payments, Servers, Tariffs) - VPN provider abstraction with MockProvider - Stats service with revenue/subscription analytics - Docker Compose (PostgreSQL + Redis + app) - Healthcheck endpoint
203 lines
6.5 KiB
Python
203 lines
6.5 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
|
|
from aiogram import Bot
|
|
from aiogram.exceptions import TelegramAPIError
|
|
from loguru import logger
|
|
|
|
from app.models.notification import Notification, NotificationType
|
|
from app.repositories.notification import NotificationRepository
|
|
from app.repositories.user import UserRepository
|
|
from app.settings import settings
|
|
|
|
|
|
_REMINDER_DAYS = (7, 3, 1)
|
|
|
|
_DAYS_LABEL = {7: "7 days", 3: "3 days", 1: "1 day"}
|
|
|
|
|
|
class NotificationService:
|
|
def __init__(
|
|
self,
|
|
notification_repo: NotificationRepository,
|
|
user_repo: UserRepository,
|
|
bot: Bot | None = None,
|
|
):
|
|
self.notification_repo = notification_repo
|
|
self.user_repo = user_repo
|
|
self._bot = bot
|
|
|
|
def _get_bot(self) -> Bot | None:
|
|
if self._bot is None and settings.bot_token:
|
|
self._bot = Bot(token=settings.bot_token)
|
|
return self._bot
|
|
|
|
async def send(
|
|
self,
|
|
user_id: int,
|
|
title: str,
|
|
text: str,
|
|
type: NotificationType = NotificationType.INFO,
|
|
) -> Notification:
|
|
user = await self.user_repo.get(user_id)
|
|
if user is None:
|
|
raise ValueError(f"User not found: {user_id}")
|
|
|
|
notification = await self.notification_repo.create(
|
|
user_id=user_id,
|
|
type=type,
|
|
title=title,
|
|
text=text,
|
|
)
|
|
|
|
await self._deliver(user, text)
|
|
|
|
logger.debug(
|
|
"Notification sent: user={} type={} title={}",
|
|
user_id,
|
|
type.value,
|
|
title,
|
|
)
|
|
return notification
|
|
|
|
async def send_expiry_reminder(
|
|
self, user_id: int, remaining_days: int, tariff_name: str
|
|
) -> Notification | None:
|
|
if remaining_days not in _REMINDER_DAYS:
|
|
return None
|
|
|
|
user = await self.user_repo.get(user_id)
|
|
if user is None:
|
|
raise ValueError(f"User not found: {user_id}")
|
|
|
|
label = _DAYS_LABEL.get(remaining_days, f"{remaining_days} days")
|
|
|
|
notification = await self.notification_repo.create(
|
|
user_id=user_id,
|
|
type=NotificationType.WARNING,
|
|
title="Subscription Expiring Soon",
|
|
text=(
|
|
f"Your subscription ({tariff_name}) expires in {label}.\n"
|
|
"Please renew to avoid service interruption."
|
|
),
|
|
)
|
|
|
|
await self._deliver(
|
|
user,
|
|
f"⚠️ <b>Your {tariff_name} subscription expires in {label}.</b>\n"
|
|
"Please renew to keep using the service.",
|
|
)
|
|
|
|
logger.info(
|
|
"Expiry reminder sent: user={} tariff={} expires_in={}d",
|
|
user_id,
|
|
tariff_name,
|
|
remaining_days,
|
|
)
|
|
return notification
|
|
|
|
async def send_subscription_expired(
|
|
self, user_id: int, tariff_name: str | None = None
|
|
) -> Notification:
|
|
user = await self.user_repo.get(user_id)
|
|
if user is None:
|
|
raise ValueError(f"User not found: {user_id}")
|
|
|
|
plan = tariff_name or "VPN"
|
|
notification = await self.notification_repo.create(
|
|
user_id=user_id,
|
|
type=NotificationType.WARNING,
|
|
title="Subscription Expired",
|
|
text=(
|
|
f"Your {plan} subscription has expired.\n"
|
|
"Please renew to restore access."
|
|
),
|
|
)
|
|
|
|
await self._deliver(
|
|
user,
|
|
f"❌ <b>Your {plan} subscription has expired.</b>\n"
|
|
"Please renew to restore access.",
|
|
)
|
|
|
|
logger.info("Expired notification sent: user={} tariff={}", user_id, plan)
|
|
return notification
|
|
|
|
async def _deliver(self, user, text: str) -> None:
|
|
bot = self._get_bot()
|
|
if bot is None:
|
|
logger.debug("No bot configured, notification not delivered")
|
|
return
|
|
|
|
try:
|
|
await bot.send_message(chat_id=user.telegram_id, text=text)
|
|
except TelegramAPIError:
|
|
logger.warning(
|
|
"Failed to deliver to user {} (tg={}), fallback to admin group",
|
|
user.id,
|
|
user.telegram_id,
|
|
)
|
|
await self._deliver_to_admin(text, user)
|
|
|
|
async def _deliver_to_admin(self, text: str, user=None) -> None:
|
|
bot = self._get_bot()
|
|
if bot is None:
|
|
return
|
|
|
|
chat_id = settings.admin_group_id
|
|
if not chat_id:
|
|
logger.debug("No admin group configured, notification dropped")
|
|
return
|
|
|
|
prefix = (
|
|
f"👤 User #{user.id} (@{user.username or '—'})\n\n"
|
|
if user
|
|
else ""
|
|
)
|
|
message_id = settings.admin_group_thread_id
|
|
|
|
try:
|
|
await bot.send_message(
|
|
chat_id=chat_id,
|
|
text=prefix + text,
|
|
message_thread_id=message_id,
|
|
)
|
|
except TelegramAPIError as e:
|
|
logger.error("Failed to deliver to admin group: {}", e)
|
|
|
|
async def send_info(self, user_id: int, title: str, text: str) -> Notification:
|
|
return await self.send(user_id, title, text, NotificationType.INFO)
|
|
|
|
async def send_warning(self, user_id: int, title: str, text: str) -> Notification:
|
|
return await self.send(user_id, title, text, NotificationType.WARNING)
|
|
|
|
async def send_success(self, user_id: int, title: str, text: str) -> Notification:
|
|
return await self.send(user_id, title, text, NotificationType.SUCCESS)
|
|
|
|
async def send_payment(
|
|
self, user_id: int, title: str, text: str
|
|
) -> Notification:
|
|
return await self.send(user_id, title, text, NotificationType.PAYMENT)
|
|
|
|
async def get_user_notifications(
|
|
self, user_id: int
|
|
) -> list[Notification]:
|
|
return await self.notification_repo.get_by_user_id(user_id)
|
|
|
|
async def get_unread(self, user_id: int) -> list[Notification]:
|
|
return await self.notification_repo.get_unread(user_id)
|
|
|
|
async def get_unread_count(self, user_id: int) -> int:
|
|
return await self.notification_repo.count_unread(user_id)
|
|
|
|
async def mark_read(self, notification_id: int) -> bool:
|
|
result = await self.notification_repo.mark_as_read(notification_id)
|
|
if result:
|
|
logger.debug("Notification marked read: id={}", notification_id)
|
|
return result
|
|
|
|
async def mark_all_read(self, user_id: int) -> int:
|
|
count = await self.notification_repo.mark_all_as_read(user_id)
|
|
if count:
|
|
logger.debug("Marked {} notifications as read: user={}", count, user_id)
|
|
return count
|