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_PASSWORD=
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
+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
__pycache__/
*.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.
## Features
- Plans:
- 1 month = ⭐️100
- 3 months = ⭐️270
@@ -13,9 +14,11 @@ Async Telegram bot for selling and extending Marzban VPN subscriptions.
- `data_limit_reset_strategy = month`
- `proxies = {"vless": {"flow": "xtls-rprx-vision"}}`
- `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.
- Bot sends **subscription URL** after successful purchase.
- 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:
- payments count
- stars earned
@@ -23,7 +26,16 @@ Async Telegram bot for selling and extending Marzban VPN subscriptions.
- marzban users
- 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
```bash
python -m venv .venv
source .venv/bin/activate
@@ -32,11 +44,57 @@ cp .env.example .env
```
Fill `.env` and run:
```bash
export $(grep -v '^#' .env | xargs)
set -a
source .env
set +a
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
- Telegram Stars invoices use `currency="XTR"` and empty `provider_token`.
- 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
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
from typing import Any
import aiohttp
@@ -19,6 +19,8 @@ from aiogram.types import (
PreCheckoutQuery,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -38,6 +40,7 @@ class Settings:
marzban_username: str
marzban_password: str
admin_ids: set[int]
proxy: str
db_path: str = "stats.db"
@classmethod
@@ -54,6 +57,7 @@ class Settings:
marzban_username=os.environ["MARZBAN_USERNAME"],
marzban_password=os.environ["MARZBAN_PASSWORD"],
admin_ids=admin_ids,
proxy=os.getenv("HTTP_PROXY", None),
db_path=os.getenv("DB_PATH", "stats.db"),
)
@@ -149,7 +153,10 @@ def bytes_to_gb(value: int | float) -> str:
class VpnBot:
def __init__(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.marzban = MarzbanClient(
settings.marzban_url,
@@ -158,6 +165,7 @@ class VpnBot:
)
self.db: aiosqlite.Connection | None = None
self.locks: dict[int, asyncio.Lock] = {}
self.admin_selected_users: dict[int, str] = {}
self._register_handlers()
async def setup(self) -> None:
@@ -186,21 +194,132 @@ class VpnBot:
def _register_handlers(self) -> None:
self.dp.message.register(self.cmd_start, Command("start"))
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.pre_checkout_query.register(self.pre_checkout)
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:
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()
for months, stars in PLANS.items():
kb.button(text=f"{months} month — ⭐️{stars}", callback_data=f"buy:{months}")
plan_lines: list[str] = []
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)
await message.answer(
"VPN subscription plans:\n"
"• 1 month — 100 stars\n"
"• 3 months — 270 stars\n"
"• 6 months — 500 stars\n\n"
"Username in VPN panel: <code>tg{your_telegram_id}</code>",
+ "\n".join(plan_lines)
+ f"\n\nUsername in VPN panel: <code>{username}</code>",
reply_markup=kb.as_markup(),
)
@@ -209,6 +328,10 @@ class VpnBot:
return
months = int(query.data.split(":", 1)[1])
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(
chat_id=query.message.chat.id,
title=f"VPN {months} month(s)",
@@ -240,8 +363,10 @@ class VpnBot:
return
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:
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(
"✅ Subscription updated!\n"
@@ -265,8 +390,8 @@ class VpnBot:
f"Expires: <code>{info['expire_human']}</code>"
)
async def buy_or_extend(self, user_id: int, months: int) -> dict[str, str]:
username = f"tg{user_id}"
async def buy_or_extend(self, user: Any, months: int, phone: str | None = None) -> dict[str, str]:
username = f"tg{user.id}"
now = datetime.now(tz=timezone.utc)
expire_from = now
action = "created"
@@ -279,14 +404,20 @@ class VpnBot:
action = "extended"
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 = {
"username": username,
"status": "active",
"data_limit": 50 * GB,
"data_limit_reset_strategy": "month",
"data_limit": data_limit,
"data_limit_reset_strategy": data_limit_reset_strategy,
"expire": int(expire_at.timestamp()),
"proxies": {"vless": {"flow": "xtls-rprx-vision"}},
"inbounds": {"vless": ["VLESS TCP REALITY"]},
"proxies": (current.get("proxies") if current else {"vless": {"flow": "xtls-rprx-vision"}}),
"inbounds": (current.get("inbounds") if current else {"vless": ["VLESS TCP REALITY"]}),
"note": note,
}
if current:
@@ -362,6 +493,110 @@ class VpnBot:
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:
settings = Settings.from_env()
+1
View File
@@ -1,3 +1,4 @@
aiogram>=3.7.0
aiohttp>=3.9.0
aiosqlite>=0.20.0
aiohttp-socks >= 0.11.0