diff --git a/IDEA.md b/IDEA.md new file mode 100644 index 0000000..b932545 --- /dev/null +++ b/IDEA.md @@ -0,0 +1 @@ +creating bot in python for tg with marzban panel diff --git a/README.md b/README.md new file mode 100644 index 0000000..0cc69eb --- /dev/null +++ b/README.md @@ -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/?start= +``` + +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. diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..409e93b --- /dev/null +++ b/bot.py @@ -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() diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..10e9f6c --- /dev/null +++ b/config.example.toml @@ -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" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f0e4c8a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6834c99 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +aiogram>=3.13,<4 +httpx>=0.27,<1 +pytest>=8,<9 diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..9f28e0a --- /dev/null +++ b/tests/test_db.py @@ -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") diff --git a/tests/test_marzban.py b/tests/test_marzban.py new file mode 100644 index 0000000..ccf3091 --- /dev/null +++ b/tests/test_marzban.py @@ -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"] diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..916ee1b --- /dev/null +++ b/tests/test_utils.py @@ -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" diff --git a/vpn_bot/__init__.py b/vpn_bot/__init__.py new file mode 100644 index 0000000..9c08a4a --- /dev/null +++ b/vpn_bot/__init__.py @@ -0,0 +1,5 @@ +"""Telegram bot for a Marzban VPN panel.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/vpn_bot/config.py b/vpn_bot/config.py new file mode 100644 index 0000000..6ea5bb2 --- /dev/null +++ b/vpn_bot/config.py @@ -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")), + ) diff --git a/vpn_bot/db.py b/vpn_bot/db.py new file mode 100644 index 0000000..bb2d63f --- /dev/null +++ b/vpn_bot/db.py @@ -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() diff --git a/vpn_bot/handlers.py b/vpn_bot/handlers.py new file mode 100644 index 0000000..39d8f2b --- /dev/null +++ b/vpn_bot/handlers.py @@ -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 diff --git a/vpn_bot/keyboards.py b/vpn_bot/keyboards.py new file mode 100644 index 0000000..675e51a --- /dev/null +++ b/vpn_bot/keyboards.py @@ -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")], + ] + ) diff --git a/vpn_bot/marzban.py b/vpn_bot/marzban.py new file mode 100644 index 0000000..2ca8cf5 --- /dev/null +++ b/vpn_bot/marzban.py @@ -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 diff --git a/vpn_bot/messages.py b/vpn_bot/messages.py new file mode 100644 index 0000000..bfb5b37 --- /dev/null +++ b/vpn_bot/messages.py @@ -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 diff --git a/vpn_bot/utils.py b/vpn_bot/utils.py new file mode 100644 index 0000000..dd6e171 --- /dev/null +++ b/vpn_bot/utils.py @@ -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"