import asyncio
import calendar
import logging
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any
import aiohttp
import aiosqlite
from aiogram import Bot, Dispatcher, F
from aiogram.enums import ParseMode
from aiogram.filters import Command
from aiogram.types import (
CallbackQuery,
LabeledPrice,
Message,
PreCheckoutQuery,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
GB = 1024**3
PLANS = {
1: 100,
3: 270,
6: 500,
}
@dataclass
class Settings:
bot_token: str
marzban_url: str
marzban_username: str
marzban_password: str
admin_ids: set[int]
proxy: str
db_path: str = "stats.db"
@classmethod
def from_env(cls) -> "Settings":
admin_raw = os.getenv("ADMIN_IDS", "")
admin_ids = {
int(part.strip())
for part in admin_raw.split(",")
if part.strip().isdigit()
}
return cls(
bot_token=os.environ["BOT_TOKEN"],
marzban_url=os.environ["MARZBAN_URL"].rstrip("/"),
marzban_username=os.environ["MARZBAN_USERNAME"],
marzban_password=os.environ["MARZBAN_PASSWORD"],
admin_ids=admin_ids,
proxy=os.getenv("HTTP_PROXY", None),
db_path=os.getenv("DB_PATH", "stats.db"),
)
class MarzbanClient:
def __init__(self, base_url: str, username: str, password: str):
self.base_url = base_url
self.username = username
self.password = password
self._session: aiohttp.ClientSession | None = None
self._token: str | None = None
async def __aenter__(self) -> "MarzbanClient":
self._session = aiohttp.ClientSession(base_url=self.base_url)
await self._authenticate()
return self
async def __aexit__(self, exc_type, exc, tb):
if self._session:
await self._session.close()
async def _authenticate(self) -> None:
assert self._session
resp = await self._session.post(
"/api/admin/token",
data={"username": self.username, "password": self.password},
)
resp.raise_for_status()
data = await resp.json()
self._token = data["access_token"]
async def _request(self, method: str, path: str, **kwargs) -> Any:
assert self._session
if self._token is None:
await self._authenticate()
headers = kwargs.pop("headers", {})
headers["Authorization"] = f"Bearer {self._token}"
resp = await self._session.request(method, path, headers=headers, **kwargs)
if resp.status == 401:
await self._authenticate()
headers["Authorization"] = f"Bearer {self._token}"
resp = await self._session.request(method, path, headers=headers, **kwargs)
if resp.status == 404:
return None
resp.raise_for_status()
if resp.content_type.startswith("application/json"):
return await resp.json()
return await resp.text()
async def get_user(self, username: str) -> dict[str, Any] | None:
return await self._request("GET", f"/api/user/{username}")
async def create_user(self, payload: dict[str, Any]) -> dict[str, Any]:
return await self._request("POST", "/api/user", json=payload)
async def update_user(self, username: str, payload: dict[str, Any]) -> dict[str, Any]:
return await self._request("PUT", f"/api/user/{username}", json=payload)
async def list_all_users(self) -> list[dict[str, Any]]:
users: list[dict[str, Any]] = []
offset = 0
limit = 100
while True:
chunk = await self._request("GET", f"/api/users?offset={offset}&limit={limit}")
if not chunk:
break
batch = chunk.get("users", []) if isinstance(chunk, dict) else chunk
if not batch:
break
users.extend(batch)
if len(batch) < limit:
break
offset += len(batch)
return users
def add_months(dt: datetime, months: int) -> datetime:
month = dt.month - 1 + months
year = dt.year + month // 12
month = month % 12 + 1
day = min(dt.day, calendar.monthrange(year, month)[1])
return dt.replace(year=year, month=month, day=day)
def bytes_to_gb(value: int | float) -> str:
return f"{Decimal(value) / Decimal(GB):.2f} GB"
class VpnBot:
def __init__(self, settings: Settings):
self.settings = settings
self.session = AiohttpSession(proxy=settings.proxy)
self.bot = Bot(settings.bot_token,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
session=self.session)
self.dp = Dispatcher()
self.marzban = MarzbanClient(
settings.marzban_url,
settings.marzban_username,
settings.marzban_password,
)
self.db: aiosqlite.Connection | None = None
self.locks: dict[int, asyncio.Lock] = {}
self._register_handlers()
async def setup(self) -> None:
self.db = await aiosqlite.connect(self.settings.db_path)
await self.db.execute(
"""
CREATE TABLE IF NOT EXISTS payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
tg_username TEXT,
marzban_username TEXT NOT NULL,
months INTEGER NOT NULL,
stars INTEGER NOT NULL,
action TEXT NOT NULL,
created_at TEXT NOT NULL
)
"""
)
await self.db.commit()
async def run(self) -> None:
async with self.marzban:
await self.setup()
await self.dp.start_polling(self.bot)
def _register_handlers(self) -> None:
self.dp.message.register(self.cmd_start, Command("start"))
self.dp.message.register(self.cmd_stats, Command("stats"))
self.dp.callback_query.register(self.choose_plan, F.data.startswith("buy:"))
self.dp.pre_checkout_query.register(self.pre_checkout)
self.dp.message.register(self.successful_payment, F.successful_payment)
async def cmd_start(self, message: Message) -> None:
kb = InlineKeyboardBuilder()
for months, stars in PLANS.items():
kb.button(text=f"{months} month — ⭐️{stars}", callback_data=f"buy:{months}")
kb.adjust(1)
await message.answer(
"VPN subscription plans:\n"
"• 1 month — 100 stars\n"
"• 3 months — 270 stars\n"
"• 6 months — 500 stars\n\n"
"Username in VPN panel: tg{your_telegram_id}",
reply_markup=kb.as_markup(),
)
async def choose_plan(self, query: CallbackQuery) -> None:
if not query.data or not query.message:
return
months = int(query.data.split(":", 1)[1])
stars = PLANS[months]
await self.bot.send_invoice(
chat_id=query.message.chat.id,
title=f"VPN {months} month(s)",
description="Buy or extend VPN subscription",
payload=f"vpn:{months}",
provider_token="",
currency="XTR",
prices=[LabeledPrice(label=f"{months} month(s)", amount=stars)],
)
await query.answer()
async def pre_checkout(self, pre_checkout_query: PreCheckoutQuery) -> None:
payload = pre_checkout_query.invoice_payload
ok = payload.startswith("vpn:")
await self.bot.answer_pre_checkout_query(
pre_checkout_query.id,
ok=ok,
error_message="Invalid plan" if not ok else None,
)
async def successful_payment(self, message: Message) -> None:
payment = message.successful_payment
if not payment:
return
try:
months = int(payment.invoice_payload.split(":", 1)[1])
except (IndexError, ValueError):
await message.answer("Payment received, but payload is invalid. Contact admin.")
return
lock = self.locks.setdefault(message.from_user.id, asyncio.Lock())
async with lock:
info = await self.buy_or_extend(message.from_user.id, months)
await message.answer(
"✅ Subscription updated!\n"
f"Plan: {months} month(s), paid: ⭐️{payment.total_amount}\n"
f"Expires at (UTC): {info['expire_human']}\n"
f"Subscription URL:\n{info['subscription_url']}"
)
await self.save_payment(
user_id=message.from_user.id,
tg_username=message.from_user.username,
marzban_username=info["username"],
months=months,
stars=payment.total_amount,
action=info["action"],
)
await self.notify_admins(
f"💸 User {message.from_user.id} ({message.from_user.full_name}) "
f"{info['action']} subscription for {months} month(s) and paid ⭐️{payment.total_amount}.\n"
f"Marzban username: {info['username']}\n"
f"Expires: {info['expire_human']}"
)
async def buy_or_extend(self, user_id: int, months: int) -> dict[str, str]:
username = f"tg{user_id}"
now = datetime.now(tz=timezone.utc)
expire_from = now
action = "created"
current = await self.marzban.get_user(username)
if current:
current_expire = int(current.get("expire") or 0)
if current_expire > int(now.timestamp()):
expire_from = datetime.fromtimestamp(current_expire, tz=timezone.utc)
action = "extended"
expire_at = add_months(expire_from, months)
payload = {
"username": username,
"status": "active",
"data_limit": 50 * GB,
"data_limit_reset_strategy": "month",
"expire": int(expire_at.timestamp()),
"proxies": {"vless": {"flow": "xtls-rprx-vision"}},
"inbounds": {"vless": ["VLESS TCP REALITY"]},
}
if current:
updated = await self.marzban.update_user(username, payload)
else:
updated = await self.marzban.create_user(payload)
subscription_url = updated.get("subscription_url") or "(not returned by API)"
return {
"username": username,
"action": action,
"subscription_url": subscription_url,
"expire_human": expire_at.strftime("%Y-%m-%d %H:%M:%S"),
}
async def save_payment(
self,
user_id: int,
tg_username: str | None,
marzban_username: str,
months: int,
stars: int,
action: str,
) -> None:
assert self.db
await self.db.execute(
"""
INSERT INTO payments (user_id, tg_username, marzban_username, months, stars, action, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
tg_username,
marzban_username,
months,
stars,
action,
datetime.now(tz=timezone.utc).isoformat(),
),
)
await self.db.commit()
async def notify_admins(self, text: str) -> None:
for admin_id in self.settings.admin_ids:
try:
await self.bot.send_message(admin_id, text)
except Exception as exc:
logger.warning("Failed to notify admin %s: %s", admin_id, exc)
async def cmd_stats(self, message: Message) -> None:
if message.from_user.id not in self.settings.admin_ids:
await message.answer("Admins only")
return
assert self.db
cursor = await self.db.execute(
"SELECT COUNT(*), COALESCE(SUM(stars), 0), COALESCE(SUM(months), 0) FROM payments"
)
pay_count, stars_total, months_total = await cursor.fetchone()
users = await self.marzban.list_all_users()
user_count = len(users)
used = sum(int(u.get("used_traffic") or 0) for u in users)
quota = sum(int(u.get("data_limit") or 0) for u in users)
await message.answer(
"📊 Stats\n"
f"Payments: {pay_count}\n"
f"Stars earned: ⭐️{stars_total}\n"
f"Sold months: {months_total}\n"
f"Marzban users: {user_count}\n"
f"Traffic used: {bytes_to_gb(used)}\n"
f"Traffic quota: {bytes_to_gb(quota)}"
)
async def main() -> None:
settings = Settings.from_env()
bot = VpnBot(settings)
await bot.run()
if __name__ == "__main__":
asyncio.run(main())