from __future__ import annotations import asyncio import logging from datetime import datetime from pathlib import Path from aiogram import Bot from aiogram.types import FSInputFile from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.db.base import utcnow from app.db.models import SubscriptionNotification from app.services.bot_config_service import BotConfigSnapshot, StaticBotConfigService from app.services.sync_service import SyncService from app.utils.formatters import format_datetime_with_days_left logger = logging.getLogger(__name__) NOTIFICATION_TYPE_EXPIRE_SOON = "EXPIRE_SOON" class SubscriptionNotificationService: def __init__( self, *, session_factory: async_sessionmaker[AsyncSession], sync_service: SyncService, config_service: StaticBotConfigService, project_root: Path, ) -> None: self._session_factory = session_factory self._sync_service = sync_service self._config_service = config_service self._project_root = project_root async def _get_config(self) -> BotConfigSnapshot: return await self._config_service.get_snapshot() def _resolve_image_path(self, config: BotConfigSnapshot) -> Path | None: if not config.bot_start_image_enabled: return None raw_path = config.bot_start_image_path.strip() default_path = self._project_root / "assets" / "main.png" if not raw_path: return default_path if default_path.is_file() else None image_path = Path(raw_path).expanduser() if not image_path.is_absolute(): image_path = self._project_root / image_path return image_path if image_path.is_file() else None async def _already_notified( self, session: AsyncSession, *, remnawave_user_id: int, notification_type: str, expire_at_snapshot: datetime, ) -> bool: existing = await session.scalar( select(SubscriptionNotification.id).where( SubscriptionNotification.remnawave_user_id == remnawave_user_id, SubscriptionNotification.notification_type == notification_type, SubscriptionNotification.expire_at_snapshot == expire_at_snapshot, ) ) return existing is not None async def _record_notification( self, session: AsyncSession, *, remnawave_user_id: int, telegram_id: int, notification_type: str, expire_at_snapshot: datetime, ) -> None: session.add( SubscriptionNotification( remnawave_user_id=remnawave_user_id, telegram_id=telegram_id, notification_type=notification_type, expire_at_snapshot=expire_at_snapshot, sent_at=utcnow(), ) ) async def check_and_notify(self, bot: Bot) -> int: config = await self._get_config() if not config.notification_enabled: return 0 days_list = config.notification_days_list if not days_list: return 0 total_sent = 0 image_path = self._resolve_image_path(config) for days_before in days_list: expiring = await self._sync_service.get_expiring_users( days_before=days_before, tolerance_hours=max(config.notification_check_interval_hours, 1), ) for rw_user_id, telegram_id, expire_at in expiring: if telegram_id is None: continue try: sent = await self._send_notification( bot, rw_user_id=rw_user_id, telegram_id=telegram_id, expire_at=expire_at, days_before=days_before, brand_name=config.bot_brand_name, image_path=image_path, ) if sent: total_sent += 1 except Exception: logger.exception( "Failed to send expiration notification to telegram_id=%s", telegram_id, ) return total_sent async def _send_notification( self, bot: Bot, *, rw_user_id: int, telegram_id: int, expire_at: datetime, days_before: int, brand_name: str, image_path: Path | None, ) -> bool: notification_type = f"{NOTIFICATION_TYPE_EXPIRE_SOON}_{days_before}d" async with self._session_factory() as session: if await self._already_notified( session, remnawave_user_id=rw_user_id, notification_type=notification_type, expire_at_snapshot=expire_at, ): return False text = self._build_notification_text( brand_name=brand_name, expire_at=expire_at, days_before=days_before, ) try: if image_path is not None: await bot.send_photo( chat_id=telegram_id, photo=FSInputFile(str(image_path)), caption=text, ) else: await bot.send_message( chat_id=telegram_id, text=text, ) except Exception: logger.warning( "Cannot send notification to telegram_id=%s (blocked or unavailable)", telegram_id, ) return False await self._record_notification( session, remnawave_user_id=rw_user_id, telegram_id=telegram_id, notification_type=notification_type, expire_at_snapshot=expire_at, ) await session.commit() return True @staticmethod def _build_notification_text( *, brand_name: str, expire_at: datetime, days_before: int, ) -> str: import html as html_module if days_before <= 1: urgency = "⚠️ Ваша подписка истекает завтра!" else: urgency = f"⏰ Ваша подписка истекает через {days_before} дн." lines = [ f"{html_module.escape(brand_name)}", "", urgency, "", ( "
" f"Дата истечения: {format_datetime_with_days_left(expire_at)}\n\n" "Чтобы продлить доступ, откройте бот и выберите тариф." "" ), "", "Нажмите /start чтобы открыть меню и продлить подписку.", ] return "\n".join(lines) async def run_periodic(self, bot: Bot, *, default_interval_hours: int = 6) -> None: logger.info("Subscription notification periodic task started") while True: try: config = await self._get_config() interval_hours = max(config.notification_check_interval_hours, 1) except Exception: interval_hours = max(default_interval_hours, 1) try: total = await self.check_and_notify(bot) if total > 0: logger.info("Sent %d subscription expiration notifications", total) except Exception: logger.exception("Error in subscription notification check") await asyncio.sleep(interval_hours * 3600)