240 lines
7.9 KiB
Python
240 lines
7.9 KiB
Python
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 = "⚠️ Ваша подписка истекает <b>завтра</b>!"
|
|
else:
|
|
urgency = f"⏰ Ваша подписка истекает через <b>{days_before} дн.</b>"
|
|
|
|
lines = [
|
|
f"<b>{html_module.escape(brand_name)}</b>",
|
|
"",
|
|
urgency,
|
|
"",
|
|
(
|
|
"<blockquote>"
|
|
f"Дата истечения: <b>{format_datetime_with_days_left(expire_at)}</b>\n\n"
|
|
"Чтобы продлить доступ, откройте бот и выберите тариф."
|
|
"</blockquote>"
|
|
),
|
|
"",
|
|
"<i>Нажмите /start чтобы открыть меню и продлить подписку.</i>",
|
|
]
|
|
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)
|