This commit is contained in:
2026-04-22 18:34:13 +03:00
parent 3992121397
commit ce7a1f70b2
60 changed files with 10243 additions and 3443 deletions

View 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)