Compare commits

..
10 Commits
Author SHA1 Message Date
krosh 1be33dbdf8 delete k3s instructions
Publish container / publish (push) Canceled after 0s
2026-07-16 19:17:00 +03:00
krosh 4020fedf9a add k8s support and upload image to ghcr.io 2026-07-16 19:06:33 +03:00
krosh 36a1cdee1d add Dockerfile 2026-05-28 12:52:17 +03:00
Dmitrii GudovandGitHub ed56ecdbaa Merge pull request #3 from kr0sh512/copilot/update-vpn-plans-with-multiplier
Generate `/start` plan pricing dynamically with per-user multiplier and real VPN username
2026-04-21 17:58:06 +03:00
a924f57227 Generate VPN plans text dynamically with multiplier and real tg username
Agent-Logs-Url: https://github.com/kr0sh512/tt-simple/sessions/bc21e448-4bad-4522-8609-1b248a8dc082

Co-authored-by: kr0sh512 <46405586+kr0sh512@users.noreply.github.com>
2026-04-21 14:38:16 +00:00
Dmitrii GudovandGitHub 3a632e16ed Merge pull request #2 from kr0sh512/copilot/add-admin-user-parameter-changes
Add admin-selected user parameter editing and per-user pricing metadata
2026-04-21 17:29:16 +03:00
7eb57b788b Refine admin update clarity for phone extraction and reset strategy
Agent-Logs-Url: https://github.com/kr0sh512/tt-simple/sessions/34c3052e-7b55-4cb8-9fee-0ee1ba315691

Co-authored-by: kr0sh512 <46405586+kr0sh512@users.noreply.github.com>
2026-04-21 12:07:46 +00:00
6553e4b5ff Fix data limit fallback and narrow traffic parsing exceptions
Agent-Logs-Url: https://github.com/kr0sh512/tt-simple/sessions/34c3052e-7b55-4cb8-9fee-0ee1ba315691

Co-authored-by: kr0sh512 <46405586+kr0sh512@users.noreply.github.com>
2026-04-21 12:06:24 +00:00
7da7b95e8b Add admin controls for selected user params and user note enrichment
Agent-Logs-Url: https://github.com/kr0sh512/tt-simple/sessions/34c3052e-7b55-4cb8-9fee-0ee1ba315691

Co-authored-by: kr0sh512 <46405586+kr0sh512@users.noreply.github.com>
2026-04-21 12:04:51 +00:00
krosh 6aa9f71ff6 add proxy support 2026-04-21 05:22:42 +03:00
8 changed files with 420 additions and 16 deletions
+25
View File
@@ -0,0 +1,25 @@
.git
.github
.env
.env.*
.venv
venv
ENV
__pycache__
*.py[cod]
stats.db
*.db
*.db-journal
*.db-shm
*.db-wal
.pytest_cache
.mypy_cache
.ruff_cache
run.sh
docker-compose*.yml
docker-compose*.yaml
+2
View File
@@ -3,4 +3,6 @@ MARZBAN_URL=https://your-marzban.example.com
MARZBAN_USERNAME= MARZBAN_USERNAME=
MARZBAN_PASSWORD= MARZBAN_PASSWORD=
ADMIN_IDS=12345678,87654321 ADMIN_IDS=12345678,87654321
# Optional; remove/comment this line when no proxy is required.
# HTTP_PROXY=http://shared-http-proxy.proxy.svc.cluster.local:3128
DB_PATH=stats.db DB_PATH=stats.db
+55
View File
@@ -0,0 +1,55 @@
name: Publish container
on:
push:
branches:
- main
tags:
- "v*"
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/kr0sh512/tt-simple
tags: |
type=ref,event=tag
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and publish image
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+2
View File
@@ -1,3 +1,5 @@
stats.db
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[codz] *.py[codz]
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.12-slim-bookworm
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
RUN groupadd --gid 1000 app \
&& useradd \
--uid 1000 \
--gid 1000 \
--no-create-home \
--shell /usr/sbin/nologin \
app
COPY requirements.txt ./
RUN pip install \
--no-cache-dir \
--disable-pip-version-check \
-r requirements.txt
COPY --chown=app:app bot.py ./
USER app:app
CMD ["python", "bot.py"]
+59 -1
View File
@@ -3,6 +3,7 @@
Async Telegram bot for selling and extending Marzban VPN subscriptions. Async Telegram bot for selling and extending Marzban VPN subscriptions.
## Features ## Features
- Plans: - Plans:
- 1 month = ⭐️100 - 1 month = ⭐️100
- 3 months = ⭐️270 - 3 months = ⭐️270
@@ -13,9 +14,11 @@ Async Telegram bot for selling and extending Marzban VPN subscriptions.
- `data_limit_reset_strategy = month` - `data_limit_reset_strategy = month`
- `proxies = {"vless": {"flow": "xtls-rprx-vision"}}` - `proxies = {"vless": {"flow": "xtls-rprx-vision"}}`
- `inbounds = {"vless": ["VLESS TCP REALITY"]}` - `inbounds = {"vless": ["VLESS TCP REALITY"]}`
- `note` containing Telegram first name, last name, and phone (if available)
- If user has active subscription, a new purchase extends from current expiration. - If user has active subscription, a new purchase extends from current expiration.
- Bot sends **subscription URL** after successful purchase. - Bot sends **subscription URL** after successful purchase.
- Admin notifications for every creation/extension. - Admin notifications for every creation/extension.
- Per-user price multiplier support (`price_multiplier`) that affects invoice stars (final amount is always integer).
- `/stats` command for admins: - `/stats` command for admins:
- payments count - payments count
- stars earned - stars earned
@@ -23,7 +26,16 @@ Async Telegram bot for selling and extending Marzban VPN subscriptions.
- marzban users - marzban users
- traffic usage/total quota - traffic usage/total quota
## Admin user management
- `/select_user <username|telegram_id>` — select user for further updates and show current values.
- `/selected_user` — show the currently selected user and current values.
- `/set_expire <YYYY-MM-DD or ISO datetime>` — set selected user expiration (UTC).
- `/set_traffic <GB>` — set selected user monthly traffic limit in GB.
- `/set_multiplier <float>` — set selected user price multiplier.
## Setup ## Setup
```bash ```bash
python -m venv .venv python -m venv .venv
source .venv/bin/activate source .venv/bin/activate
@@ -32,11 +44,57 @@ cp .env.example .env
``` ```
Fill `.env` and run: Fill `.env` and run:
```bash ```bash
export $(grep -v '^#' .env | xargs) set -a
source .env
set +a
python bot.py python bot.py
``` ```
## Container image
The image contains only the application and Python dependencies. Runtime secrets
and the SQLite database are deliberately excluded.
Build and verify it locally:
```bash
docker build --pull -t tt-simple:dev .
docker run --rm --entrypoint sh tt-simple:dev -c \
'test ! -e /app/.env && test ! -e /app/stats.db && test -e /app/bot.py'
```
Run it with environment variables and persistent SQLite storage:
```bash
docker volume create tt-simple-data
docker run --rm \
--env-file .env \
--env DB_PATH=/data/stats.db \
--volume tt-simple-data:/data \
tt-simple:dev
```
### Publishing to GHCR
`.github/workflows/container.yml` publishes a multi-architecture image to:
```text
ghcr.io/kr0sh512/tt-simple
```
A push to `main` publishes `latest` and `sha-<commit>` tags. A Git tag such as
`v0.1.0` publishes the matching version tag:
```bash
git tag v0.1.0
git push origin main v0.1.0
```
## Notes ## Notes
- Telegram Stars invoices use `currency="XTR"` and empty `provider_token`. - Telegram Stars invoices use `currency="XTR"` and empty `provider_token`.
- Local SQLite database stores only payment statistics. User state is read from Marzban API. - Local SQLite database stores only payment statistics. User state is read from Marzban API.
+250 -15
View File
@@ -4,7 +4,7 @@ import logging
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
from typing import Any from typing import Any
import aiohttp import aiohttp
@@ -19,6 +19,8 @@ from aiogram.types import (
PreCheckoutQuery, PreCheckoutQuery,
) )
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,6 +40,7 @@ class Settings:
marzban_username: str marzban_username: str
marzban_password: str marzban_password: str
admin_ids: set[int] admin_ids: set[int]
proxy: str
db_path: str = "stats.db" db_path: str = "stats.db"
@classmethod @classmethod
@@ -54,6 +57,7 @@ class Settings:
marzban_username=os.environ["MARZBAN_USERNAME"], marzban_username=os.environ["MARZBAN_USERNAME"],
marzban_password=os.environ["MARZBAN_PASSWORD"], marzban_password=os.environ["MARZBAN_PASSWORD"],
admin_ids=admin_ids, admin_ids=admin_ids,
proxy=os.getenv("HTTP_PROXY", None),
db_path=os.getenv("DB_PATH", "stats.db"), db_path=os.getenv("DB_PATH", "stats.db"),
) )
@@ -149,7 +153,10 @@ def bytes_to_gb(value: int | float) -> str:
class VpnBot: class VpnBot:
def __init__(self, settings: Settings): def __init__(self, settings: Settings):
self.settings = settings self.settings = settings
self.bot = Bot(settings.bot_token, parse_mode=ParseMode.HTML) self.session = AiohttpSession(proxy=settings.proxy)
self.bot = Bot(settings.bot_token,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
session=self.session)
self.dp = Dispatcher() self.dp = Dispatcher()
self.marzban = MarzbanClient( self.marzban = MarzbanClient(
settings.marzban_url, settings.marzban_url,
@@ -158,6 +165,7 @@ class VpnBot:
) )
self.db: aiosqlite.Connection | None = None self.db: aiosqlite.Connection | None = None
self.locks: dict[int, asyncio.Lock] = {} self.locks: dict[int, asyncio.Lock] = {}
self.admin_selected_users: dict[int, str] = {}
self._register_handlers() self._register_handlers()
async def setup(self) -> None: async def setup(self) -> None:
@@ -186,21 +194,132 @@ class VpnBot:
def _register_handlers(self) -> None: def _register_handlers(self) -> None:
self.dp.message.register(self.cmd_start, Command("start")) self.dp.message.register(self.cmd_start, Command("start"))
self.dp.message.register(self.cmd_stats, Command("stats")) self.dp.message.register(self.cmd_stats, Command("stats"))
self.dp.message.register(self.cmd_select_user, Command("select_user"))
self.dp.message.register(self.cmd_selected_user, Command("selected_user"))
self.dp.message.register(self.cmd_set_expire, Command("set_expire"))
self.dp.message.register(self.cmd_set_traffic, Command("set_traffic"))
self.dp.message.register(self.cmd_set_multiplier, Command("set_multiplier"))
self.dp.callback_query.register(self.choose_plan, F.data.startswith("buy:")) self.dp.callback_query.register(self.choose_plan, F.data.startswith("buy:"))
self.dp.pre_checkout_query.register(self.pre_checkout) self.dp.pre_checkout_query.register(self.pre_checkout)
self.dp.message.register(self.successful_payment, F.successful_payment) self.dp.message.register(self.successful_payment, F.successful_payment)
@staticmethod
def _extract_price_multiplier(note: str | None) -> float:
if not note:
return 1.0
for line in note.splitlines():
if line.startswith("price_multiplier="):
try:
value = float(line.split("=", 1)[1].strip())
return value if value > 0 else 1.0
except ValueError:
return 1.0
return 1.0
@staticmethod
def _set_price_multiplier_note(note: str | None, multiplier: float) -> str:
lines = [line for line in (note or "").splitlines() if line and not line.startswith("price_multiplier=")]
lines.append(f"price_multiplier={multiplier}")
return "\n".join(lines)
@staticmethod
def _format_new_user_note(first_name: str | None, last_name: str | None, phone: str | None) -> str:
parts: list[str] = []
if first_name:
parts.append(f"First name: {first_name}")
if last_name:
parts.append(f"Last name: {last_name}")
if phone:
parts.append(f"Phone: {phone}")
return "\n".join(parts)
@staticmethod
def _stars_with_multiplier(base_stars: int, multiplier: float) -> int:
value = Decimal(base_stars) * Decimal(str(multiplier))
rounded = value.quantize(Decimal("1"), rounding=ROUND_HALF_UP)
return max(1, int(rounded))
@staticmethod
def _parse_command_arg(message: Message) -> str:
text = (message.text or "").strip()
parts = text.split(maxsplit=1)
return parts[1].strip() if len(parts) > 1 else ""
@staticmethod
def _parse_utc_datetime(value: str) -> datetime:
parsed = datetime.fromisoformat(value.strip())
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
@staticmethod
def _normalize_username(value: str) -> str:
cleaned = value.strip()
if cleaned.isdigit():
return f"tg{cleaned}"
return cleaned
async def _selected_user_or_reply(self, message: Message) -> str | None:
username = self.admin_selected_users.get(message.from_user.id)
if not username:
await message.answer("No selected user. Use /select_user <username> first.")
return None
return username
async def _get_admin_user(self, message: Message, username: str) -> dict[str, Any] | None:
user = await self.marzban.get_user(username)
if not user:
await message.answer(f"User <code>{username}</code> not found in Marzban.")
return None
return user
async def _update_admin_user(self, username: str, current: dict[str, Any], **changes: Any) -> dict[str, Any]:
payload = {
"username": current.get("username", username),
"status": current.get("status", "active"),
"data_limit": int(current.get("data_limit") or 0),
"data_limit_reset_strategy": current.get("data_limit_reset_strategy") or "month",
"expire": int(current.get("expire") or 0),
"proxies": current.get("proxies") or {},
"inbounds": current.get("inbounds") or {},
"note": current.get("note") or "",
}
payload.update(changes)
return await self.marzban.update_user(username, payload)
async def _send_selected_user_info(self, message: Message, username: str, user: dict[str, Any]) -> None:
expire_ts = int(user.get("expire") or 0)
expire_human = (
datetime.fromtimestamp(expire_ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
if expire_ts
else "not set"
)
traffic = int(user.get("data_limit") or 0)
multiplier = self._extract_price_multiplier(user.get("note"))
await message.answer(
"Selected user:\n"
f"Username: <code>{username}</code>\n"
f"Expire (UTC): <code>{expire_human}</code>\n"
f"Monthly traffic: <b>{bytes_to_gb(traffic)}</b>\n"
f"Price multiplier: <b>{multiplier}</b>"
)
async def cmd_start(self, message: Message) -> None: async def cmd_start(self, message: Message) -> None:
username = f"tg{message.from_user.id}"
current = await self.marzban.get_user(username)
multiplier = self._extract_price_multiplier((current or {}).get("note"))
kb = InlineKeyboardBuilder() kb = InlineKeyboardBuilder()
for months, stars in PLANS.items(): plan_lines: list[str] = []
kb.button(text=f"{months} month — ⭐️{stars}", callback_data=f"buy:{months}") for months, base_stars in PLANS.items():
month_label = "month" if months == 1 else "months"
stars = self._stars_with_multiplier(base_stars, multiplier)
kb.button(text=f"{months} {month_label} — ⭐️{stars}", callback_data=f"buy:{months}")
plan_lines.append(f"{months} {month_label}{stars} stars")
kb.adjust(1) kb.adjust(1)
await message.answer( await message.answer(
"VPN subscription plans:\n" "VPN subscription plans:\n"
"• 1 month — 100 stars\n" + "\n".join(plan_lines)
"• 3 months — 270 stars\n" + f"\n\nUsername in VPN panel: <code>{username}</code>",
"• 6 months — 500 stars\n\n"
"Username in VPN panel: <code>tg{your_telegram_id}</code>",
reply_markup=kb.as_markup(), reply_markup=kb.as_markup(),
) )
@@ -209,6 +328,10 @@ class VpnBot:
return return
months = int(query.data.split(":", 1)[1]) months = int(query.data.split(":", 1)[1])
stars = PLANS[months] stars = PLANS[months]
username = f"tg{query.from_user.id}"
current = await self.marzban.get_user(username)
multiplier = self._extract_price_multiplier((current or {}).get("note"))
stars = self._stars_with_multiplier(stars, multiplier)
await self.bot.send_invoice( await self.bot.send_invoice(
chat_id=query.message.chat.id, chat_id=query.message.chat.id,
title=f"VPN {months} month(s)", title=f"VPN {months} month(s)",
@@ -240,8 +363,10 @@ class VpnBot:
return return
lock = self.locks.setdefault(message.from_user.id, asyncio.Lock()) lock = self.locks.setdefault(message.from_user.id, asyncio.Lock())
order_info = getattr(payment, "order_info", None)
phone = getattr(order_info, "phone_number", None) if order_info else None
async with lock: async with lock:
info = await self.buy_or_extend(message.from_user.id, months) info = await self.buy_or_extend(message.from_user, months, phone)
await message.answer( await message.answer(
"✅ Subscription updated!\n" "✅ Subscription updated!\n"
@@ -265,8 +390,8 @@ class VpnBot:
f"Expires: <code>{info['expire_human']}</code>" f"Expires: <code>{info['expire_human']}</code>"
) )
async def buy_or_extend(self, user_id: int, months: int) -> dict[str, str]: async def buy_or_extend(self, user: Any, months: int, phone: str | None = None) -> dict[str, str]:
username = f"tg{user_id}" username = f"tg{user.id}"
now = datetime.now(tz=timezone.utc) now = datetime.now(tz=timezone.utc)
expire_from = now expire_from = now
action = "created" action = "created"
@@ -279,14 +404,20 @@ class VpnBot:
action = "extended" action = "extended"
expire_at = add_months(expire_from, months) expire_at = add_months(expire_from, months)
data_limit = int(current.get("data_limit", 50 * GB)) if current else 50 * GB
data_limit_reset_strategy = (current.get("data_limit_reset_strategy") if current else None) or "month"
note = current.get("note") if current else self._format_new_user_note(
user.first_name, user.last_name, phone
)
payload = { payload = {
"username": username, "username": username,
"status": "active", "status": "active",
"data_limit": 50 * GB, "data_limit": data_limit,
"data_limit_reset_strategy": "month", "data_limit_reset_strategy": data_limit_reset_strategy,
"expire": int(expire_at.timestamp()), "expire": int(expire_at.timestamp()),
"proxies": {"vless": {"flow": "xtls-rprx-vision"}}, "proxies": (current.get("proxies") if current else {"vless": {"flow": "xtls-rprx-vision"}}),
"inbounds": {"vless": ["VLESS TCP REALITY"]}, "inbounds": (current.get("inbounds") if current else {"vless": ["VLESS TCP REALITY"]}),
"note": note,
} }
if current: if current:
@@ -362,6 +493,110 @@ class VpnBot:
f"Traffic quota: <b>{bytes_to_gb(quota)}</b>" f"Traffic quota: <b>{bytes_to_gb(quota)}</b>"
) )
async def cmd_select_user(self, message: Message) -> None:
if message.from_user.id not in self.settings.admin_ids:
await message.answer("Admins only")
return
arg = self._parse_command_arg(message)
if not arg:
await message.answer("Usage: /select_user <username|telegram_id>")
return
username = self._normalize_username(arg)
user = await self._get_admin_user(message, username)
if not user:
return
self.admin_selected_users[message.from_user.id] = username
await self._send_selected_user_info(message, username, user)
async def cmd_selected_user(self, message: Message) -> None:
if message.from_user.id not in self.settings.admin_ids:
await message.answer("Admins only")
return
username = await self._selected_user_or_reply(message)
if not username:
return
user = await self._get_admin_user(message, username)
if not user:
return
await self._send_selected_user_info(message, username, user)
async def cmd_set_expire(self, message: Message) -> None:
if message.from_user.id not in self.settings.admin_ids:
await message.answer("Admins only")
return
arg = self._parse_command_arg(message)
if not arg:
await message.answer("Usage: /set_expire <YYYY-MM-DD or ISO datetime>")
return
username = await self._selected_user_or_reply(message)
if not username:
return
try:
expire_dt = self._parse_utc_datetime(arg)
except ValueError:
await message.answer("Invalid datetime format. Example: 2026-12-31 or 2026-12-31T15:30:00")
return
current = await self._get_admin_user(message, username)
if not current:
return
updated = await self._update_admin_user(username, current, expire=int(expire_dt.timestamp()))
await self._send_selected_user_info(message, username, updated)
async def cmd_set_traffic(self, message: Message) -> None:
if message.from_user.id not in self.settings.admin_ids:
await message.answer("Admins only")
return
arg = self._parse_command_arg(message)
if not arg:
await message.answer("Usage: /set_traffic <GB>")
return
username = await self._selected_user_or_reply(message)
if not username:
return
try:
gb_value = Decimal(arg)
except (ValueError, InvalidOperation):
await message.answer("Invalid value. Example: /set_traffic 75")
return
if gb_value <= 0:
await message.answer("Traffic must be greater than 0.")
return
data_limit = int((gb_value * Decimal(GB)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
current = await self._get_admin_user(message, username)
if not current:
return
updated = await self._update_admin_user(username, current, data_limit=data_limit, data_limit_reset_strategy="month")
await self._send_selected_user_info(message, username, updated)
async def cmd_set_multiplier(self, message: Message) -> None:
if message.from_user.id not in self.settings.admin_ids:
await message.answer("Admins only")
return
arg = self._parse_command_arg(message)
if not arg:
await message.answer("Usage: /set_multiplier <float>")
return
username = await self._selected_user_or_reply(message)
if not username:
return
try:
multiplier = float(arg)
except ValueError:
await message.answer("Invalid multiplier. Example: /set_multiplier 1.25")
return
if multiplier <= 0:
await message.answer("Multiplier must be greater than 0.")
return
current = await self._get_admin_user(message, username)
if not current:
return
updated = await self._update_admin_user(
username,
current,
note=self._set_price_multiplier_note(current.get("note"), multiplier),
)
await self._send_selected_user_info(message, username, updated)
async def main() -> None: async def main() -> None:
settings = Settings.from_env() settings = Settings.from_env()
+1
View File
@@ -1,3 +1,4 @@
aiogram>=3.7.0 aiogram>=3.7.0
aiohttp>=3.9.0 aiohttp>=3.9.0
aiosqlite>=0.20.0 aiosqlite>=0.20.0
aiohttp-socks >= 0.11.0