Initial commit: VPN Telegram bot (sanitized defaults)

This commit is contained in:
VPN Service
2026-07-26 23:21:41 +03:00
commit aaf04faa96
77 changed files with 33249 additions and 0 deletions
+254
View File
@@ -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)