Files
2026-07-09 23:49:42 +03:00

281 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from aiogram import Bot, Dispatcher, F, Router
from aiogram.filters import CommandStart
from aiogram.types import CallbackQuery, Message
from .config import Config
from .db import Database
from .keyboards import admin_menu, extend_keyboard, main_menu, reset_confirm_keyboard
from .marzban import MarzbanClient, MarzbanError
from .messages import format_vpn_info, generate_invite_token, payment_amount, user_display_name
from .utils import build_marzban_note, format_marzban_username, format_timestamp, now_ts
@dataclass
class PendingPayment:
months: int
amount: int
class BotApp:
def __init__(self, *, config: Config, db: Database, marzban: MarzbanClient):
self.config = config
self.db = db
self.marzban = marzban
self.router = Router()
self.pending_payments: dict[int, PendingPayment] = {}
self._register_handlers()
def is_admin(self, user_id: int | None) -> bool:
return user_id is not None and user_id in self.config.admin_ids
def _register_handlers(self) -> None:
self.router.message(CommandStart())(self.start)
self.router.message(F.text == "➕ Новое приглашение")(self.new_invite)
self.router.message(F.text == "/new_user")(self.new_invite)
self.router.message(F.text == "📄 Информация о VPN")(self.vpn_info)
self.router.message(F.text == "💳 Продлить VPN")(self.extend_menu)
self.router.message(F.text == "🔄 Сбросить трафик")(self.reset_explain)
self.router.message(F.contact)(self.save_contact)
self.router.callback_query(F.data.startswith("extend:"))(self.select_extend)
self.router.callback_query(F.data == "reset:confirm")(self.confirm_reset)
self.router.callback_query(F.data == "reset:cancel")(self.cancel_reset)
self.router.message(F.photo | F.document)(self.payment_proof)
async def start(self, message: Message) -> None:
user_id = message.from_user.id if message.from_user else None
args = message.text.split(maxsplit=1)[1].strip() if message.text and " " in message.text else ""
if args:
await self.activate_invite(message, args)
return
if self.is_admin(user_id):
await message.answer("Меню администратора", reply_markup=admin_menu())
return
if user_id and self.db.get_by_tg_user_id(user_id):
await message.answer("С возвращением.", reply_markup=main_menu())
return
await message.answer("Попросите администратора выдать ссылку-приглашение.")
async def new_invite(self, message: Message) -> None:
if not self.is_admin(message.from_user.id if message.from_user else None):
await message.answer("Создавать приглашения могут только администраторы.")
return
bot_user = await message.bot.get_me()
token = generate_invite_token()
self.db.create_invite(token=token, admin_id=message.from_user.id, created_at=now_ts())
link = f"https://t.me/{bot_user.username}?start={token}"
text = f"Ссылка-приглашение создана:\n{link}"
await message.answer(text)
await self.notify_admins(message.bot, f"➕ Новое приглашение создано администратором {message.from_user.id}\n{link}")
async def activate_invite(self, message: Message, token: str) -> None:
tg = message.from_user
if tg is None:
return
existing = self.db.get_by_tg_user_id(tg.id)
if existing:
await message.answer("Вы уже активированы.", reply_markup=main_menu())
return
invite = self.db.get_by_token(token)
if invite is None:
await message.answer("Некорректная ссылка-приглашение.")
return
if invite["tg_user_id"] is not None:
await message.answer("Эта ссылка-приглашение уже использована.")
return
marzban_username = format_marzban_username(tg.id)
try:
await self.marzban.create_trial_user(
tg_user_id=tg.id,
first_name=tg.first_name,
last_name=tg.last_name,
tg_username=tg.username,
)
self.db.activate_invite(
token=token,
tg_user_id=tg.id,
tg_username=tg.username,
tg_first_name=tg.first_name,
tg_last_name=tg.last_name,
tg_phone=None,
marzban_username=marzban_username,
activated_at=now_ts(),
)
user = await self.marzban.get_user(marzban_username)
except (MarzbanError, ValueError) as exc:
await message.answer(f"Не удалось активировать VPN: {exc}")
await self.notify_admins(message.bot, f"⚠️ Ошибка активации для {tg.id}: {exc}")
return
await message.answer("Пробный VPN активирован на 3 дня / 10 GB.", reply_markup=main_menu())
await message.answer(format_vpn_info(user))
await self.notify_admins(
message.bot,
f"✅ Пользователь активировал приглашение\nTelegram: {tg.first_name or ''} {tg.last_name or ''} @{tg.username or '-'}\nMarzban: {marzban_username}",
)
async def get_current_local_user(self, message_or_query: Message | CallbackQuery) -> dict[str, Any] | None:
from_user = message_or_query.from_user
if from_user is None:
return None
return self.db.get_by_tg_user_id(from_user.id)
async def vpn_info(self, message: Message) -> None:
row = await self.get_current_local_user(message)
if not row or not row.get("marzban_username"):
await message.answer("Вы не активированы. Сначала откройте ссылку-приглашение.")
return
try:
user = await self.marzban.get_user(row["marzban_username"])
except MarzbanError as exc:
await message.answer(f"Не удалось получить информацию о VPN: {exc}")
return
await message.answer(format_vpn_info(user))
async def extend_menu(self, message: Message) -> None:
row = await self.get_current_local_user(message)
if not row:
await message.answer("Вы не активированы. Сначала откройте ссылку-приглашение.")
return
await message.answer("Выберите срок продления:", reply_markup=extend_keyboard())
async def select_extend(self, query: CallbackQuery) -> None:
if query.from_user is None:
return
row = self.db.get_by_tg_user_id(query.from_user.id)
if not row:
await query.message.answer("Вы не активированы. Сначала откройте ссылку-приглашение.")
await query.answer()
return
months = int(query.data.split(":", 1)[1])
amount = payment_amount(months)
self.pending_payments[query.from_user.id] = PendingPayment(months=months, amount=amount)
await query.message.answer(
f"Выбрано продление: {months} мес., {amount} ₽.\n\n"
f"{self.config.payment_text}\n\n"
"После оплаты пришлите сюда скриншот/фото или файл с подтверждением."
)
await query.answer()
async def payment_proof(self, message: Message) -> None:
if message.from_user is None:
return
pending = self.pending_payments.get(message.from_user.id)
if pending is None:
return
row = self.db.get_by_tg_user_id(message.from_user.id)
if not row or not row.get("marzban_username"):
await message.answer("Вы не активированы. Сначала откройте ссылку-приглашение.")
return
try:
user = await self.marzban.extend_user(row["marzban_username"], pending.months)
except MarzbanError as exc:
await message.answer(f"Не удалось продлить VPN: {exc}")
await self.notify_admins(message.bot, f"⚠️ Ошибка продления для {row['marzban_username']}: {exc}")
return
self.pending_payments.pop(message.from_user.id, None)
await message.answer("Подтверждение оплаты получено. VPN автоматически продлён.")
await message.answer(format_vpn_info(user))
caption = (
"💳 Получено подтверждение оплаты, VPN продлён\n"
f"Пользователь: {user_display_name(row)}\n"
f"Marzban: {row['marzban_username']}\n"
f"Период: {pending.months} мес.\n"
f"Сумма: {pending.amount}\n"
f"Новая дата окончания: {format_timestamp(user.get('expire'))}"
)
await self.forward_payment_to_admins(message, caption)
async def reset_explain(self, message: Message) -> None:
row = await self.get_current_local_user(message)
if not row:
await message.answer("Вы не активированы. Сначала откройте ссылку-приглашение.")
return
await message.answer(
"Сброс трафика восстановит лимит до 50 GB.\n\n"
"Важно: все дни текущего неполного оплаченного месяца будут удалены. "
"Оставшееся время VPN будет округлено вниз до целых периодов по 30 дней.\n\n"
"Пример: если осталось 1 месяц и 12 дней, после сброса останется ровно 1 месяц.\n\n"
"Продолжить?",
reply_markup=reset_confirm_keyboard(),
)
async def confirm_reset(self, query: CallbackQuery) -> None:
row = self.db.get_by_tg_user_id(query.from_user.id)
if not row or not row.get("marzban_username"):
await query.message.answer("Вы не активированы. Сначала откройте ссылку-приглашение.")
await query.answer()
return
try:
user, full_months, _new_expire = await self.marzban.reset_paid_traffic_with_time_penalty(row["marzban_username"])
except MarzbanError as exc:
await query.message.answer(f"Не удалось сбросить трафик: {exc}")
await query.answer()
return
await query.message.answer(
f"Трафик сброшен. Оставшееся время округлено вниз до {full_months} полн. мес."
)
await query.message.answer(format_vpn_info(user))
await query.answer()
async def cancel_reset(self, query: CallbackQuery) -> None:
await query.message.answer("Сброс отменён.")
await query.answer()
async def save_contact(self, message: Message) -> None:
if not message.from_user or not message.contact:
return
if message.contact.user_id and message.contact.user_id != message.from_user.id:
await message.answer("Пожалуйста, отправьте свой контакт, а не чужой.")
return
phone = message.contact.phone_number
self.db.update_phone(tg_user_id=message.from_user.id, phone=phone)
row = self.db.get_by_tg_user_id(message.from_user.id)
if row and row.get("marzban_username"):
note = build_marzban_note(
first_name=row.get("tg_first_name"),
last_name=row.get("tg_last_name"),
username=row.get("tg_username"),
phone=phone,
)
try:
await self.marzban.modify_user(row["marzban_username"], {"note": note})
except MarzbanError:
await message.answer("Телефон сохранён локально, но сейчас не удалось обновить заметку в Marzban.")
return
await message.answer("Телефон сохранён.", reply_markup=main_menu())
async def notify_admins(self, bot: Bot, text: str) -> None:
for admin_id in self.config.admin_ids:
try:
await bot.send_message(admin_id, text)
except Exception:
pass
async def forward_payment_to_admins(self, message: Message, caption: str) -> None:
for admin_id in self.config.admin_ids:
try:
if message.photo:
await message.bot.send_photo(admin_id, message.photo[-1].file_id, caption=caption)
elif message.document:
await message.bot.send_document(admin_id, message.document.file_id, caption=caption)
else:
await message.bot.send_message(admin_id, caption)
except Exception:
pass
def build_dispatcher(app: BotApp) -> Dispatcher:
dp = Dispatcher()
dp.include_router(app.router)
return dp