281 lines
14 KiB
Python
281 lines
14 KiB
Python
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
|