first init
This commit is contained in:
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,3 @@
|
||||
aiogram>=3.13,<4
|
||||
httpx>=0.27,<1
|
||||
pytest>=8,<9
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Telegram bot for a Marzban VPN panel."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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")],
|
||||
]
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user