first init

This commit is contained in:
2026-07-09 23:39:40 +03:00
parent 31281ad8cc
commit cb573a52d5
17 changed files with 1067 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
creating bot in python for tg with marzban panel
+96
View File
@@ -0,0 +1,96 @@
# Marzban Telegram VPN Bot
Python Telegram bot for issuing one-time VPN invite links and managing Marzban users.
## Features
- Admin-only one-time invite links.
- SQLite database with one table: `users`.
- New users are created in Marzban as `tg{telegram_user_id}`.
- Trial users: 3 days, 10 GB.
- Paid extensions:
- 1 month / 30 days — 50 ₽
- 2 months / 60 days — 100 ₽
- 3 months / 90 days — 150 ₽
- Payment proof is trusted automatically and forwarded to every admin.
- Paid users get 50 GB after payment.
- Traffic reset restores 50 GB and floors remaining time to whole 30-day months after an explicit confirmation.
## Marzban API requirements
On the Marzban panel enable API docs if needed:
```env
DOCS=True
```
Then the panel exposes Swagger at:
```text
https://your-panel.example/docs
```
The bot uses:
- `POST /api/admin/token`
- `POST /api/user`
- `GET /api/user/{username}`
- `PUT /api/user/{username}`
- `POST /api/user/{username}/reset`
## Install
```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp config.example.toml config.toml
```
Edit `config.toml`.
## Run
```bash
python bot.py --config config.toml
```
## Admin usage
Start the bot from an admin Telegram account, then use:
```text
/new_user
```
or the ` New invite` button.
The bot returns a one-time link:
```text
https://t.me/<bot_username>?start=<token>
```
Send it to the user.
## User flow
1. User opens the invite link.
2. Bot creates Marzban user `tg{telegram_user_id}` with:
```json
{
"proxies": {"vless": {"flow": "xtls-rprx-vision"}},
"inbounds": {"vless": ["VLESS TCP REALITY"]},
"expire": "now + 3 days",
"data_limit": "10 GiB",
"data_limit_reset_strategy": "no_reset",
"status": "active"
}
```
3. User can view VPN info, extend, or reset traffic.
## Notes
The bot does not store VPN state locally. Expiration, traffic, status, subscription URL, and links are fetched from Marzban whenever shown.
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import argparse
import asyncio
import logging
from aiogram import Bot
from vpn_bot.config import Config
from vpn_bot.db import Database
from vpn_bot.handlers import BotApp, build_dispatcher
from vpn_bot.marzban import MarzbanClient
async def run(config_path: str) -> None:
config = Config.load(config_path)
logging.basicConfig(level=logging.INFO)
db = Database(config.database_path)
db.init()
bot = Bot(config.telegram_bot_token)
marzban = MarzbanClient(
base_url=config.marzban_url,
username=config.marzban_username,
password=config.marzban_password,
)
app = BotApp(config=config, db=db, marzban=marzban)
dp = build_dispatcher(app)
try:
await dp.start_polling(bot)
finally:
await marzban.close()
await bot.session.close()
def main() -> None:
parser = argparse.ArgumentParser(description="Telegram bot for Marzban VPN panel")
parser.add_argument("-c", "--config", default="config.toml", help="Path to config TOML file")
args = parser.parse_args()
asyncio.run(run(args.config))
if __name__ == "__main__":
main()
+14
View File
@@ -0,0 +1,14 @@
telegram_bot_token = "123456:replace-me"
admin_ids = [856850518]
marzban_url = "https://vpn.example.com"
marzban_username = "admin"
marzban_password = "replace-me"
payment_text = """
Pay to: +7 XXX XXX XX XX
Amount depends on selected period.
After payment, send screenshot/photo or PDF here.
"""
database_path = "bot.sqlite3"
+3
View File
@@ -0,0 +1,3 @@
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
+3
View File
@@ -0,0 +1,3 @@
aiogram>=3.13,<4
httpx>=0.27,<1
pytest>=8,<9
+71
View File
@@ -0,0 +1,71 @@
from pathlib import Path
from vpn_bot.db import Database
def test_invite_is_stored_before_activation_and_can_be_loaded(tmp_path: Path):
db = Database(tmp_path / "bot.sqlite3")
db.init()
db.create_invite(token="abc", admin_id=42, created_at=1000)
user = db.get_by_token("abc")
assert user is not None
assert user["invite_token"] == "abc"
assert user["invite_created_by_admin_id"] == 42
assert user["tg_user_id"] is None
def test_activation_stores_telegram_identity_and_marzban_username(tmp_path: Path):
db = Database(tmp_path / "bot.sqlite3")
db.init()
db.create_invite(token="abc", admin_id=42, created_at=1000)
db.activate_invite(
token="abc",
tg_user_id=856850518,
tg_username="krosh",
tg_first_name="Dmitry",
tg_last_name="Gudov",
tg_phone=None,
marzban_username="tg856850518",
activated_at=1100,
)
user = db.get_by_tg_user_id(856850518)
assert user is not None
assert user["invite_token"] == "abc"
assert user["marzban_username"] == "tg856850518"
assert user["tg_first_name"] == "Dmitry"
def test_used_invite_cannot_be_activated_twice(tmp_path: Path):
db = Database(tmp_path / "bot.sqlite3")
db.init()
db.create_invite(token="abc", admin_id=42, created_at=1000)
db.activate_invite(
token="abc",
tg_user_id=1,
tg_username=None,
tg_first_name="One",
tg_last_name=None,
tg_phone=None,
marzban_username="tg1",
activated_at=1100,
)
try:
db.activate_invite(
token="abc",
tg_user_id=2,
tg_username=None,
tg_first_name="Two",
tg_last_name=None,
tg_phone=None,
marzban_username="tg2",
activated_at=1200,
)
except ValueError as exc:
assert "already used" in str(exc)
else:
raise AssertionError("second activation should fail")
+23
View File
@@ -0,0 +1,23 @@
from vpn_bot.marzban import MarzbanClient
from vpn_bot.utils import GIB
def test_build_trial_user_payload_matches_required_proxy_inbound_and_limits():
payload = MarzbanClient.build_trial_user_payload(
tg_user_id=856850518,
first_name="Dmitry",
last_name="Gudov",
tg_username="krosh",
phone="+79990000000",
now=1_700_000_000,
)
assert payload["username"] == "tg856850518"
assert payload["proxies"] == {"vless": {"flow": "xtls-rprx-vision"}}
assert payload["inbounds"] == {"vless": ["VLESS TCP REALITY"]}
assert payload["expire"] == 1_700_000_000 + 3 * 24 * 60 * 60
assert payload["data_limit"] == 10 * GIB
assert payload["data_limit_reset_strategy"] == "no_reset"
assert payload["status"] == "active"
assert "Phone: +79990000000" in payload["note"]
assert "856850518" not in payload["note"]
+67
View File
@@ -0,0 +1,67 @@
from datetime import datetime, timezone
from vpn_bot.utils import (
GIB,
add_days_from_base,
bytes_to_human,
build_marzban_note,
calculate_reset_expire,
format_marzban_username,
)
def test_format_marzban_username_uses_tg_prefix():
assert format_marzban_username(856850518) == "tg856850518"
def test_build_marzban_note_omits_telegram_id_and_missing_phone():
note = build_marzban_note(
first_name="Dmitry",
last_name="Gudov",
username="dima",
phone=None,
)
assert "First name: Dmitry" in note
assert "Last name: Gudov" in note
assert "Username: @dima" in note
assert "Phone:" not in note
assert "856" not in note
def test_add_days_from_future_expiration_extends_from_future_timestamp():
now = 1_700_000_000
future = now + 2 * 24 * 60 * 60
assert add_days_from_base(current_expire=future, days=30, now=now) == future + 30 * 24 * 60 * 60
def test_add_days_from_expired_account_extends_from_now():
now = 1_700_000_000
expired = now - 10
assert add_days_from_base(current_expire=expired, days=30, now=now) == now + 30 * 24 * 60 * 60
def test_calculate_reset_expire_floors_remaining_time_to_whole_30_day_months():
now = 1_700_000_000
expire = now + (1 * 30 + 12) * 24 * 60 * 60
new_expire, full_months = calculate_reset_expire(expire=expire, now=now)
assert full_months == 1
assert new_expire == now + 30 * 24 * 60 * 60
def test_calculate_reset_expire_discards_partial_month_when_less_than_one_month_left():
now = 1_700_000_000
expire = now + 12 * 24 * 60 * 60
new_expire, full_months = calculate_reset_expire(expire=expire, now=now)
assert full_months == 0
assert new_expire == now
def test_bytes_to_human_formats_gib():
assert bytes_to_human(50 * GIB) == "50.00 GB"
+5
View File
@@ -0,0 +1,5 @@
"""Telegram bot for a Marzban VPN panel."""
__all__ = ["__version__"]
__version__ = "0.1.0"
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class Config:
telegram_bot_token: str
admin_ids: set[int]
marzban_url: str
marzban_username: str
marzban_password: str
payment_text: str
database_path: str = "bot.sqlite3"
@classmethod
def load(cls, path: str | Path) -> "Config":
data: dict[str, Any] = tomllib.loads(Path(path).read_text(encoding="utf-8"))
return cls(
telegram_bot_token=str(data["telegram_bot_token"]),
admin_ids={int(item) for item in data.get("admin_ids", [])},
marzban_url=str(data["marzban_url"]).rstrip("/"),
marzban_username=str(data["marzban_username"]),
marzban_password=str(data["marzban_password"]),
payment_text=str(data["payment_text"]),
database_path=str(data.get("database_path", "bot.sqlite3")),
)
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
from typing import Any
class Database:
def __init__(self, path: str | Path):
self.path = Path(path)
def connect(self) -> sqlite3.Connection:
self.path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self.path)
conn.row_factory = sqlite3.Row
return conn
def init(self) -> None:
with self.connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invite_token TEXT NOT NULL UNIQUE,
invite_created_at INTEGER NOT NULL,
invite_created_by_admin_id INTEGER NOT NULL,
tg_user_id INTEGER UNIQUE,
tg_username TEXT,
tg_first_name TEXT,
tg_last_name TEXT,
tg_phone TEXT,
marzban_username TEXT UNIQUE,
activated_at INTEGER
)
"""
)
conn.commit()
@staticmethod
def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
return dict(row) if row is not None else None
def create_invite(self, *, token: str, admin_id: int, created_at: int) -> None:
with self.connect() as conn:
conn.execute(
"""
INSERT INTO users (invite_token, invite_created_at, invite_created_by_admin_id)
VALUES (?, ?, ?)
""",
(token, created_at, admin_id),
)
conn.commit()
def get_by_token(self, token: str) -> dict[str, Any] | None:
with self.connect() as conn:
row = conn.execute("SELECT * FROM users WHERE invite_token = ?", (token,)).fetchone()
return self._row_to_dict(row)
def get_by_tg_user_id(self, tg_user_id: int) -> dict[str, Any] | None:
with self.connect() as conn:
row = conn.execute("SELECT * FROM users WHERE tg_user_id = ?", (tg_user_id,)).fetchone()
return self._row_to_dict(row)
def activate_invite(
self,
*,
token: str,
tg_user_id: int,
tg_username: str | None,
tg_first_name: str | None,
tg_last_name: str | None,
tg_phone: str | None,
marzban_username: str,
activated_at: int,
) -> None:
with self.connect() as conn:
row = conn.execute("SELECT * FROM users WHERE invite_token = ?", (token,)).fetchone()
if row is None:
raise ValueError("invite token not found")
if row["tg_user_id"] is not None:
raise ValueError("invite token already used")
conn.execute(
"""
UPDATE users
SET tg_user_id = ?,
tg_username = ?,
tg_first_name = ?,
tg_last_name = ?,
tg_phone = ?,
marzban_username = ?,
activated_at = ?
WHERE invite_token = ?
""",
(
tg_user_id,
tg_username,
tg_first_name,
tg_last_name,
tg_phone,
marzban_username,
activated_at,
token,
),
)
conn.commit()
def update_phone(self, *, tg_user_id: int, phone: str) -> None:
with self.connect() as conn:
conn.execute("UPDATE users SET tg_phone = ? WHERE tg_user_id = ?", (phone, tg_user_id))
conn.commit()
+280
View File
@@ -0,0 +1,280 @@
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 == " New invite")(self.new_invite)
self.router.message(F.text == "/new_user")(self.new_invite)
self.router.message(F.text == "📄 VPN info")(self.vpn_info)
self.router.message(F.text == "💳 Extend VPN")(self.extend_menu)
self.router.message(F.text == "🔄 Reset traffic")(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("Admin menu", reply_markup=admin_menu())
return
if user_id and self.db.get_by_tg_user_id(user_id):
await message.answer("Welcome back.", reply_markup=main_menu())
return
await message.answer("Ask an admin for an invite link.")
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("Only admins can create invite links.")
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"Invite link created:\n{link}"
await message.answer(text)
await self.notify_admins(message.bot, f" New invite created by admin {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("You are already activated.", reply_markup=main_menu())
return
invite = self.db.get_by_token(token)
if invite is None:
await message.answer("Invalid invite link.")
return
if invite["tg_user_id"] is not None:
await message.answer("This invite link was already used.")
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"Activation failed: {exc}")
await self.notify_admins(message.bot, f"⚠️ Activation failed for {tg.id}: {exc}")
return
await message.answer("VPN trial activated for 3 days / 10 GB.", reply_markup=main_menu())
await message.answer(format_vpn_info(user))
await self.notify_admins(
message.bot,
f"✅ User activated invite\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("You are not activated. Use an invite link first.")
return
try:
user = await self.marzban.get_user(row["marzban_username"])
except MarzbanError as exc:
await message.answer(f"Could not fetch VPN info: {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("You are not activated. Use an invite link first.")
return
await message.answer("Choose extension period:", 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("You are not activated. Use an invite link first.")
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"Extension selected: {months} month(s), {amount} ₽.\n\n"
f"{self.config.payment_text}\n\n"
"After payment, send a screenshot/photo or file here."
)
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("You are not activated. Use an invite link first.")
return
try:
user = await self.marzban.extend_user(row["marzban_username"], pending.months)
except MarzbanError as exc:
await message.answer(f"Could not extend VPN: {exc}")
await self.notify_admins(message.bot, f"⚠️ Extension failed for {row['marzban_username']}: {exc}")
return
self.pending_payments.pop(message.from_user.id, None)
await message.answer("Payment proof received. VPN was extended automatically.")
await message.answer(format_vpn_info(user))
caption = (
"💳 Payment proof received and VPN extended\n"
f"User: {user_display_name(row)}\n"
f"Marzban: {row['marzban_username']}\n"
f"Period: {pending.months} month(s)\n"
f"Amount: {pending.amount}\n"
f"New expiration: {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("You are not activated. Use an invite link first.")
return
await message.answer(
"Reset traffic will restore your traffic limit to 50 GB.\n\n"
"Important: all days of your current partial paid month will be removed. "
"Your remaining VPN time will be rounded down to full 30-day months.\n\n"
"Example: if you have 1 month and 12 days left, after reset you will have exactly 1 month left.\n\n"
"Do you want to continue?",
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("You are not activated. Use an invite link first.")
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"Could not reset traffic: {exc}")
await query.answer()
return
await query.message.answer(
f"Traffic reset complete. Remaining time was rounded down to {full_months} full month(s)."
)
await query.message.answer(format_vpn_info(user))
await query.answer()
async def cancel_reset(self, query: CallbackQuery) -> None:
await query.message.answer("Reset cancelled.")
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("Please share your own contact, not another contact.")
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("Phone saved locally, but I could not update Marzban note now.")
return
await message.answer("Phone saved.", 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
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ReplyKeyboardMarkup
def main_menu() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="📄 VPN info")],
[KeyboardButton(text="💳 Extend VPN"), KeyboardButton(text="🔄 Reset traffic")],
[KeyboardButton(text="📱 Share phone", request_contact=True)],
],
resize_keyboard=True,
)
def admin_menu() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[[KeyboardButton(text=" New invite")]],
resize_keyboard=True,
)
def extend_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="1 month — 50 ₽", callback_data="extend:1")],
[InlineKeyboardButton(text="2 months — 100 ₽", callback_data="extend:2")],
[InlineKeyboardButton(text="3 months — 150 ₽", callback_data="extend:3")],
]
)
def reset_confirm_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="✅ Confirm reset", callback_data="reset:confirm")],
[InlineKeyboardButton(text="❌ Cancel", callback_data="reset:cancel")],
]
)
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
from typing import Any
import httpx
from .utils import (
DAYS_PER_MONTH,
PAID_TRAFFIC_BYTES,
TRIAL_DAYS,
TRIAL_TRAFFIC_BYTES,
add_days_from_base,
build_marzban_note,
calculate_reset_expire,
format_marzban_username,
now_ts,
)
DEFAULT_PROXIES = {"vless": {"flow": "xtls-rprx-vision"}}
DEFAULT_INBOUNDS = {"vless": ["VLESS TCP REALITY"]}
class MarzbanError(RuntimeError):
pass
class MarzbanClient:
def __init__(self, *, base_url: str, username: str, password: str, timeout: float = 30.0):
self.base_url = base_url.rstrip("/")
self.username = username
self.password = password
self.timeout = timeout
self._token: str | None = None
self._client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout)
async def close(self) -> None:
await self._client.aclose()
@staticmethod
def build_trial_user_payload(
*,
tg_user_id: int,
first_name: str | None,
last_name: str | None,
tg_username: str | None,
phone: str | None,
now: int | None = None,
) -> dict[str, Any]:
current = now_ts() if now is None else now
return {
"username": format_marzban_username(tg_user_id),
"proxies": DEFAULT_PROXIES,
"inbounds": DEFAULT_INBOUNDS,
"expire": current + TRIAL_DAYS * 24 * 60 * 60,
"data_limit": TRIAL_TRAFFIC_BYTES,
"data_limit_reset_strategy": "no_reset",
"status": "active",
"note": build_marzban_note(
first_name=first_name,
last_name=last_name,
username=tg_username,
phone=phone,
),
}
async def authenticate(self) -> str:
response = await self._client.post(
"/api/admin/token",
data={"username": self.username, "password": self.password},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if response.status_code >= 400:
raise MarzbanError(f"Marzban auth failed: {response.status_code} {response.text}")
payload = response.json()
token = payload.get("access_token")
if not token:
raise MarzbanError("Marzban auth response does not contain access_token")
self._token = str(token)
return self._token
async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
if not self._token:
await self.authenticate()
headers = kwargs.pop("headers", {})
headers["Authorization"] = f"Bearer {self._token}"
response = await self._client.request(method, path, headers=headers, **kwargs)
if response.status_code == 401:
await self.authenticate()
headers["Authorization"] = f"Bearer {self._token}"
response = await self._client.request(method, path, headers=headers, **kwargs)
if response.status_code >= 400:
raise MarzbanError(f"Marzban API error {method} {path}: {response.status_code} {response.text}")
if not response.content:
return None
return response.json()
async def create_trial_user(
self,
*,
tg_user_id: int,
first_name: str | None,
last_name: str | None,
tg_username: str | None,
phone: str | None = None,
) -> dict[str, Any]:
payload = self.build_trial_user_payload(
tg_user_id=tg_user_id,
first_name=first_name,
last_name=last_name,
tg_username=tg_username,
phone=phone,
)
return await self._request("POST", "/api/user", json=payload)
async def get_user(self, username: str) -> dict[str, Any]:
return await self._request("GET", f"/api/user/{username}")
async def modify_user(self, username: str, payload: dict[str, Any]) -> dict[str, Any]:
return await self._request("PUT", f"/api/user/{username}", json=payload)
async def reset_user_usage(self, username: str) -> dict[str, Any] | None:
return await self._request("POST", f"/api/user/{username}/reset")
async def extend_user(self, username: str, months: int) -> dict[str, Any]:
user = await self.get_user(username)
current_expire = user.get("expire")
new_expire = add_days_from_base(current_expire=current_expire, days=months * DAYS_PER_MONTH)
payload = {
"expire": new_expire,
"data_limit": PAID_TRAFFIC_BYTES,
"data_limit_reset_strategy": "no_reset",
"status": "active",
}
await self.modify_user(username, payload)
await self.reset_user_usage(username)
return await self.get_user(username)
async def reset_paid_traffic_with_time_penalty(self, username: str) -> tuple[dict[str, Any], int, int]:
user = await self.get_user(username)
new_expire, full_months = calculate_reset_expire(expire=user.get("expire"))
payload = {
"expire": new_expire,
"data_limit": PAID_TRAFFIC_BYTES,
"data_limit_reset_strategy": "no_reset",
"status": "active",
}
await self.modify_user(username, payload)
await self.reset_user_usage(username)
fresh = await self.get_user(username)
return fresh, full_months, new_expire
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import secrets
from typing import Any
from .utils import bytes_to_human, format_remaining_days, format_timestamp
def generate_invite_token() -> str:
return secrets.token_urlsafe(24)
def user_display_name(row: dict[str, Any]) -> str:
parts = [row.get("tg_first_name"), row.get("tg_last_name")]
name = " ".join(part for part in parts if part)
username = row.get("tg_username")
if username:
username = username if username.startswith("@") else f"@{username}"
if name and username:
return f"{name} ({username})"
return name or username or str(row.get("tg_user_id") or "unknown")
def format_vpn_info(user: dict[str, Any]) -> str:
links = user.get("links") or []
subscription_url = user.get("subscription_url")
lines = [
"📄 VPN info",
"",
f"Username: {user.get('username', 'unknown')}",
f"Status: {user.get('status', 'unknown')}",
f"Expires: {format_timestamp(user.get('expire'))}",
f"Remaining: {format_remaining_days(user.get('expire'))}",
f"Traffic: {bytes_to_human(user.get('used_traffic'))} / {bytes_to_human(user.get('data_limit'))}",
]
if subscription_url:
lines.extend(["", "Subscription URL:", str(subscription_url)])
if links:
lines.append("")
lines.append("Config links:")
lines.extend(str(link) for link in links)
return "\n".join(lines)
def payment_amount(months: int) -> int:
if months not in (1, 2, 3):
raise ValueError("months must be 1, 2 or 3")
return months * 50
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import math
import time
from datetime import datetime, timezone
SECONDS_PER_DAY = 24 * 60 * 60
DAYS_PER_MONTH = 30
SECONDS_PER_MONTH = DAYS_PER_MONTH * SECONDS_PER_DAY
GIB = 1024 * 1024 * 1024
TRIAL_DAYS = 3
TRIAL_TRAFFIC_BYTES = 10 * GIB
PAID_TRAFFIC_BYTES = 50 * GIB
def now_ts() -> int:
return int(time.time())
def format_marzban_username(tg_user_id: int) -> str:
return f"tg{tg_user_id}"
def add_days_from_base(*, current_expire: int | None, days: int, now: int | None = None) -> int:
current = now_ts() if now is None else now
base = current_expire if current_expire and current_expire > current else current
return int(base + days * SECONDS_PER_DAY)
def calculate_reset_expire(*, expire: int | None, now: int | None = None) -> tuple[int, int]:
current = now_ts() if now is None else now
if not expire or expire <= current:
return current, 0
remaining = expire - current
full_months = math.floor(remaining / SECONDS_PER_MONTH)
return int(current + full_months * SECONDS_PER_MONTH), full_months
def build_marzban_note(
*,
first_name: str | None,
last_name: str | None,
username: str | None,
phone: str | None,
) -> str:
lines: list[str] = []
if first_name:
lines.append(f"First name: {first_name}")
if last_name:
lines.append(f"Last name: {last_name}")
if username:
normalized = username if username.startswith("@") else f"@{username}"
lines.append(f"Username: {normalized}")
if phone:
lines.append(f"Phone: {phone}")
return "\n".join(lines)
def bytes_to_human(value: int | None) -> str:
if value is None:
return "unlimited"
if value == 0:
return "unlimited"
return f"{value / GIB:.2f} GB"
def format_timestamp(ts: int | None) -> str:
if not ts:
return "unlimited"
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
def format_remaining_days(expire: int | None, *, now: int | None = None) -> str:
if not expire:
return "unlimited"
current = now_ts() if now is None else now
if expire <= current:
return "expired"
days = (expire - current) / SECONDS_PER_DAY
return f"{days:.1f} days"