Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1be33dbdf8 | ||
|
|
4020fedf9a | ||
|
|
36a1cdee1d | ||
|
|
ed56ecdbaa | ||
|
|
a924f57227 | ||
|
|
3a632e16ed | ||
|
|
7eb57b788b | ||
|
|
6553e4b5ff | ||
|
|
7da7b95e8b | ||
|
|
6aa9f71ff6 |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1,3 +1,5 @@
|
||||
stats.db
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
|
||||
+26
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
aiogram>=3.7.0
|
||||
aiohttp>=3.9.0
|
||||
aiosqlite>=0.20.0
|
||||
aiohttp-socks >= 0.11.0
|
||||
|
||||
Reference in New Issue
Block a user