v2.0
This commit is contained in:
239
app/services/notification_service.py
Normal file
239
app/services/notification_service.py
Normal file
@@ -0,0 +1,239 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user