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