Initial commit: VPN Telegram bot (sanitized defaults)
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
"""Напоминания об окончании подписки и автопродление с баланса."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from aiogram import Bot
|
||||
from remnawave import RemnawaveSDK
|
||||
|
||||
from config import get_settings
|
||||
from services.db import Database
|
||||
from services.mailer import notify_important
|
||||
from services.remnawave_users import find_panel_user
|
||||
from services.purchase import purchase_tariff
|
||||
from services.tariffs import Tariff, get_tariff
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REMIND_WITHIN_DAYS = 7
|
||||
# Автопродление: в последние сутки или уже истекло (до 3 суток простоя)
|
||||
AUTO_RENEW_MAX_PAST_DAYS = 3
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _as_utc(dt: datetime) -> datetime:
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _days_left(expire_at: datetime) -> float:
|
||||
return (_as_utc(expire_at) - _utc_now()).total_seconds() / 86400.0
|
||||
|
||||
|
||||
def _utc_today() -> date:
|
||||
return _utc_now().date()
|
||||
|
||||
|
||||
async def _notify(
|
||||
bot: Bot | None,
|
||||
telegram_id: int,
|
||||
text: str,
|
||||
*,
|
||||
db: Database | None = None,
|
||||
subject: str = "VPN Service",
|
||||
) -> None:
|
||||
if db is not None:
|
||||
try:
|
||||
settings = get_settings(require_bot_token=False)
|
||||
except SystemExit:
|
||||
settings = None
|
||||
if settings is not None:
|
||||
await notify_important(
|
||||
db=db,
|
||||
settings=settings,
|
||||
telegram_id=telegram_id,
|
||||
html=text,
|
||||
subject=subject,
|
||||
bot=bot,
|
||||
)
|
||||
return
|
||||
if bot is None or telegram_id <= 0:
|
||||
return
|
||||
try:
|
||||
await bot.send_message(telegram_id, text, parse_mode="HTML")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("renewal notify failed tg=%s", telegram_id)
|
||||
|
||||
|
||||
def _format_days_left(days: float) -> str:
|
||||
if days < 0:
|
||||
n = int(abs(days))
|
||||
if n == 0:
|
||||
return "истекла сегодня"
|
||||
return f"истекла {n} дн. назад"
|
||||
if days < 1:
|
||||
return "менее суток"
|
||||
n = int(round(days))
|
||||
if n == 1:
|
||||
return "1 день"
|
||||
if 2 <= n <= 4:
|
||||
return f"{n} дня"
|
||||
return f"{n} дней"
|
||||
|
||||
|
||||
async def process_user_subscription_renewal(
|
||||
db: Database,
|
||||
sdk: RemnawaveSDK,
|
||||
tariffs: list[Tariff],
|
||||
telegram_id: int,
|
||||
*,
|
||||
bot: Bot | None = None,
|
||||
login: str | None = None,
|
||||
) -> None:
|
||||
if telegram_id <= 0:
|
||||
return
|
||||
|
||||
bot_user = db.get_user(telegram_id)
|
||||
login = login or (bot_user.login if bot_user else None)
|
||||
|
||||
panel_user = await find_panel_user(sdk, telegram_id, login=login)
|
||||
if panel_user is None or not panel_user.expire_at:
|
||||
return
|
||||
|
||||
expire = _as_utc(panel_user.expire_at)
|
||||
days = _days_left(expire)
|
||||
|
||||
if days > REMIND_WITHIN_DAYS:
|
||||
return
|
||||
|
||||
tariff_id = db.get_last_tariff_id(telegram_id)
|
||||
if not tariff_id:
|
||||
return
|
||||
tariff = get_tariff(tariffs, tariff_id)
|
||||
if not tariff:
|
||||
return
|
||||
|
||||
balance = db.get_balance(telegram_id)
|
||||
can_auto = balance >= tariff.price
|
||||
|
||||
bot_user = db.get_user(telegram_id)
|
||||
device_blocked = bool(bot_user and bot_user.device_blocked)
|
||||
auto_renew_on = bool(bot_user.auto_renew) if bot_user else True
|
||||
|
||||
# Автопродление — только если пользователь включил опцию
|
||||
should_auto = days <= 1.0 or (
|
||||
days < 0 and days >= -AUTO_RENEW_MAX_PAST_DAYS
|
||||
)
|
||||
if should_auto and auto_renew_on and can_auto and not device_blocked:
|
||||
result = await purchase_tariff(
|
||||
db=db,
|
||||
sdk=sdk,
|
||||
telegram_id=telegram_id,
|
||||
tariff=tariff,
|
||||
charge_balance=True,
|
||||
renew=True,
|
||||
payment_meta=f"auto_renew:{tariff.id}",
|
||||
)
|
||||
if result.ok:
|
||||
await _notify(
|
||||
bot,
|
||||
telegram_id,
|
||||
(
|
||||
"<b>✅ Подписка продлена автоматически</b>\n\n"
|
||||
f"Тариф: <b>{tariff.title}</b>\n"
|
||||
f"Списано: <b>{tariff.format_price()}</b> с баланса\n"
|
||||
f"Баланс: <b>{result.balance:.0f} ₽</b>\n\n"
|
||||
"Отключить автопродление можно в кабинете в любой момент."
|
||||
),
|
||||
db=db,
|
||||
subject="Подписка продлена — VPN Service",
|
||||
)
|
||||
db.mark_expiry_reminder_sent(telegram_id, _utc_today())
|
||||
return
|
||||
logger.info(
|
||||
"auto renew failed tg=%s: %s",
|
||||
telegram_id,
|
||||
result.message[:120],
|
||||
)
|
||||
|
||||
# Напоминание раз в сутки (UTC)
|
||||
if db.expiry_reminder_sent_on(telegram_id) == _utc_today():
|
||||
return
|
||||
|
||||
expire_label = expire.strftime("%d.%m.%Y %H:%M UTC")
|
||||
left_label = _format_days_left(days)
|
||||
lines = [
|
||||
"<b>⏳ Подписка скоро закончится</b>\n",
|
||||
f"Осталось: <b>{left_label}</b>\n",
|
||||
f"Действует до: <b>{expire_label}</b>\n",
|
||||
f"Тариф для продления: <b>{tariff.title}</b> · {tariff.format_price()}\n",
|
||||
f"Баланс: <b>{balance:.0f} ₽</b>\n",
|
||||
]
|
||||
if auto_renew_on and can_auto and days <= 1.0:
|
||||
lines.append(
|
||||
"\nАвтопродление включено — при достаточном балансе подписка продлится сама."
|
||||
)
|
||||
elif auto_renew_on and can_auto:
|
||||
lines.append(
|
||||
f"\nАвтопродление включено. За сутки до окончания спишем "
|
||||
f"<b>{tariff.format_price()}</b> с баланса."
|
||||
)
|
||||
elif auto_renew_on and device_blocked:
|
||||
lines.append(
|
||||
"\n⚠️ Подписка отключена из‑за превышения устройств — "
|
||||
"сначала оплати доп. слоты или отвяжи лишние устройства."
|
||||
)
|
||||
elif auto_renew_on:
|
||||
need = max(0.0, tariff.price - balance)
|
||||
lines.append(
|
||||
f"\nПополни баланс минимум на <b>{need:.0f} ₽</b> "
|
||||
f"для автопродления или продли вручную в боте / Mini App."
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
"\nАвтопродление выключено. Включите в кабинете или продлите тариф вручную."
|
||||
)
|
||||
|
||||
await _notify(
|
||||
bot,
|
||||
telegram_id,
|
||||
"".join(lines),
|
||||
db=db,
|
||||
subject="Подписка скоро закончится — VPN Service",
|
||||
)
|
||||
db.mark_expiry_reminder_sent(telegram_id, _utc_today())
|
||||
|
||||
|
||||
async def run_subscription_renewal_cycle(
|
||||
db: Database,
|
||||
sdk: RemnawaveSDK,
|
||||
tariffs: list[Tariff],
|
||||
*,
|
||||
bot: Bot | None = None,
|
||||
limit: int = 500,
|
||||
) -> int:
|
||||
ids = db.list_telegram_ids_with_purchases(limit=limit)
|
||||
done = 0
|
||||
for tid in ids:
|
||||
try:
|
||||
await process_user_subscription_renewal(
|
||||
db, sdk, tariffs, tid, bot=bot
|
||||
)
|
||||
done += 1
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("subscription renewal tg=%s", tid)
|
||||
return done
|
||||
|
||||
|
||||
async def subscription_renewal_worker(
|
||||
db: Database,
|
||||
sdk: RemnawaveSDK,
|
||||
bot: Bot,
|
||||
tariffs: list[Tariff],
|
||||
*,
|
||||
interval_sec: int = 6 * 3600,
|
||||
) -> None:
|
||||
"""Проверка несколько раз в сутки; напоминание — не чаще 1 раза в день на пользователя."""
|
||||
await asyncio.sleep(90)
|
||||
while True:
|
||||
try:
|
||||
n = await run_subscription_renewal_cycle(
|
||||
db, sdk, tariffs, bot=bot
|
||||
)
|
||||
if n:
|
||||
logger.info("subscription renewal cycle: %s users", n)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("subscription renewal cycle error")
|
||||
await asyncio.sleep(interval_sec)
|
||||
Reference in New Issue
Block a user