translate to rus

This commit is contained in:
2026-07-09 23:49:42 +03:00
parent cb573a52d5
commit 11f03eae41
8 changed files with 89 additions and 79 deletions
+2 -2
View File
@@ -76,9 +76,9 @@ class Database:
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")
raise ValueError("ссылка-приглашение не найдена")
if row["tg_user_id"] is not None:
raise ValueError("invite token already used")
raise ValueError("ссылка-приглашение уже использована")
conn.execute(
"""
UPDATE users
+46 -46
View File
@@ -35,11 +35,11 @@ class BotApp:
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 == " Новое приглашение")(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.text == "📄 Информация о VPN")(self.vpn_info)
self.router.message(F.text == "💳 Продлить VPN")(self.extend_menu)
self.router.message(F.text == "🔄 Сбросить трафик")(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)
@@ -55,26 +55,26 @@ class BotApp:
return
if self.is_admin(user_id):
await message.answer("Admin menu", reply_markup=admin_menu())
await message.answer("Меню администратора", 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())
await message.answer("С возвращением.", reply_markup=main_menu())
return
await message.answer("Ask an admin for an invite link.")
await message.answer("Попросите администратора выдать ссылку-приглашение.")
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.")
await message.answer("Создавать приглашения могут только администраторы.")
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}"
text = f"Ссылка-приглашение создана:\n{link}"
await message.answer(text)
await self.notify_admins(message.bot, f" New invite created by admin {message.from_user.id}\n{link}")
await self.notify_admins(message.bot, f" Новое приглашение создано администратором {message.from_user.id}\n{link}")
async def activate_invite(self, message: Message, token: str) -> None:
tg = message.from_user
@@ -82,14 +82,14 @@ class BotApp:
return
existing = self.db.get_by_tg_user_id(tg.id)
if existing:
await message.answer("You are already activated.", reply_markup=main_menu())
await message.answer("Вы уже активированы.", reply_markup=main_menu())
return
invite = self.db.get_by_token(token)
if invite is None:
await message.answer("Invalid invite link.")
await message.answer("Некорректная ссылка-приглашение.")
return
if invite["tg_user_id"] is not None:
await message.answer("This invite link was already used.")
await message.answer("Эта ссылка-приглашение уже использована.")
return
marzban_username = format_marzban_username(tg.id)
@@ -112,15 +112,15 @@ class BotApp:
)
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}")
await message.answer(f"Не удалось активировать VPN: {exc}")
await self.notify_admins(message.bot, f"⚠️ Ошибка активации для {tg.id}: {exc}")
return
await message.answer("VPN trial activated for 3 days / 10 GB.", reply_markup=main_menu())
await message.answer("Пробный VPN активирован на 3 дня / 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}",
f"Пользователь активировал приглашение\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:
@@ -132,37 +132,37 @@ class BotApp:
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.")
await message.answer("Вы не активированы. Сначала откройте ссылку-приглашение.")
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}")
await message.answer(f"Не удалось получить информацию о VPN: {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.")
await message.answer("Вы не активированы. Сначала откройте ссылку-приглашение.")
return
await message.answer("Choose extension period:", reply_markup=extend_keyboard())
await message.answer("Выберите срок продления:", 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.message.answer("Вы не активированы. Сначала откройте ссылку-приглашение.")
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"Выбрано продление: {months} мес., {amount} ₽.\n\n"
f"{self.config.payment_text}\n\n"
"After payment, send a screenshot/photo or file here."
"После оплаты пришлите сюда скриншот/фото или файл с подтверждением."
)
await query.answer()
@@ -174,68 +174,68 @@ class BotApp:
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.")
await message.answer("Вы не активированы. Сначала откройте ссылку-приглашение.")
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}")
await message.answer(f"Не удалось продлить VPN: {exc}")
await self.notify_admins(message.bot, f"⚠️ Ошибка продления для {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("Подтверждение оплаты получено. VPN автоматически продлён.")
await message.answer(format_vpn_info(user))
caption = (
"💳 Payment proof received and VPN extended\n"
f"User: {user_display_name(row)}\n"
"💳 Получено подтверждение оплаты, VPN продлён\n"
f"Пользователь: {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'))}"
f"Период: {pending.months} мес.\n"
f"Сумма: {pending.amount}\n"
f"Новая дата окончания: {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.")
await message.answer("Вы не активированы. Сначала откройте ссылку-приглашение.")
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?",
"Сброс трафика восстановит лимит до 50 GB.\n\n"
"Важно: все дни текущего неполного оплаченного месяца будут удалены. "
"Оставшееся время VPN будет округлено вниз до целых периодов по 30 дней.\n\n"
"Пример: если осталось 1 месяц и 12 дней, после сброса останется ровно 1 месяц.\n\n"
"Продолжить?",
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.message.answer("Вы не активированы. Сначала откройте ссылку-приглашение.")
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.message.answer(f"Не удалось сбросить трафик: {exc}")
await query.answer()
return
await query.message.answer(
f"Traffic reset complete. Remaining time was rounded down to {full_months} full month(s)."
f"Трафик сброшен. Оставшееся время округлено вниз до {full_months} полн. мес."
)
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.message.answer("Сброс отменён.")
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.")
await message.answer("Пожалуйста, отправьте свой контакт, а не чужой.")
return
phone = message.contact.phone_number
self.db.update_phone(tg_user_id=message.from_user.id, phone=phone)
@@ -250,9 +250,9 @@ class BotApp:
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.")
await message.answer("Телефон сохранён локально, но сейчас не удалось обновить заметку в Marzban.")
return
await message.answer("Phone saved.", reply_markup=main_menu())
await message.answer("Телефон сохранён.", reply_markup=main_menu())
async def notify_admins(self, bot: Bot, text: str) -> None:
for admin_id in self.config.admin_ids:
+9 -9
View File
@@ -6,9 +6,9 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, KeyboardBu
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)],
[KeyboardButton(text="📄 Информация о VPN")],
[KeyboardButton(text="💳 Продлить VPN"), KeyboardButton(text="🔄 Сбросить трафик")],
[KeyboardButton(text="📱 Поделиться телефоном", request_contact=True)],
],
resize_keyboard=True,
)
@@ -16,7 +16,7 @@ def main_menu() -> ReplyKeyboardMarkup:
def admin_menu() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[[KeyboardButton(text=" New invite")]],
keyboard=[[KeyboardButton(text=" Новое приглашение")]],
resize_keyboard=True,
)
@@ -24,9 +24,9 @@ def admin_menu() -> ReplyKeyboardMarkup:
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")],
[InlineKeyboardButton(text="1 месяц — 50 ₽", callback_data="extend:1")],
[InlineKeyboardButton(text="2 месяца — 100 ₽", callback_data="extend:2")],
[InlineKeyboardButton(text="3 месяца — 150 ₽", callback_data="extend:3")],
]
)
@@ -34,7 +34,7 @@ def extend_keyboard() -> InlineKeyboardMarkup:
def reset_confirm_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="Confirm reset", callback_data="reset:confirm")],
[InlineKeyboardButton(text="Cancel", callback_data="reset:cancel")],
[InlineKeyboardButton(text="Подтвердить сброс", callback_data="reset:confirm")],
[InlineKeyboardButton(text="Отмена", callback_data="reset:cancel")],
]
)
+3 -3
View File
@@ -70,11 +70,11 @@ class MarzbanClient:
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if response.status_code >= 400:
raise MarzbanError(f"Marzban auth failed: {response.status_code} {response.text}")
raise MarzbanError(f"ошибка авторизации Marzban: {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")
raise MarzbanError("ответ авторизации Marzban не содержит access_token")
self._token = str(token)
return self._token
@@ -89,7 +89,7 @@ class MarzbanClient:
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}")
raise MarzbanError(f"ошибка API Marzban {method} {path}: {response.status_code} {response.text}")
if not response.content:
return None
return response.json()
+19 -9
View File
@@ -5,6 +5,14 @@ from typing import Any
from .utils import bytes_to_human, format_remaining_days, format_timestamp
STATUS_LABELS = {
"active": "активен",
"disabled": "отключён",
"limited": "лимит исчерпан",
"expired": "истёк",
"on_hold": "на удержании",
}
def generate_invite_token() -> str:
return secrets.token_urlsafe(24)
@@ -18,26 +26,28 @@ def user_display_name(row: dict[str, Any]) -> str:
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")
return name or username or str(row.get("tg_user_id") or "неизвестно")
def format_vpn_info(user: dict[str, Any]) -> str:
links = user.get("links") or []
subscription_url = user.get("subscription_url")
status = user.get("status", "неизвестно")
status_text = STATUS_LABELS.get(status, status)
lines = [
"📄 VPN info",
"📄 Информация о VPN",
"",
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'))}",
f"Пользователь: {user.get('username', 'неизвестно')}",
f"Статус: {status_text}",
f"Действует до: {format_timestamp(user.get('expire'))}",
f"Осталось: {format_remaining_days(user.get('expire'))}",
f"Трафик: {bytes_to_human(user.get('used_traffic'))} / {bytes_to_human(user.get('data_limit'))}",
]
if subscription_url:
lines.extend(["", "Subscription URL:", str(subscription_url)])
lines.extend(["", "Ссылка подписки:", str(subscription_url)])
if links:
lines.append("")
lines.append("Config links:")
lines.append("Ссылки конфигурации:")
lines.extend(str(link) for link in links)
return "\n".join(lines)
+6 -6
View File
@@ -58,23 +58,23 @@ def build_marzban_note(
def bytes_to_human(value: int | None) -> str:
if value is None:
return "unlimited"
return "безлимит"
if value == 0:
return "unlimited"
return "безлимит"
return f"{value / GIB:.2f} GB"
def format_timestamp(ts: int | None) -> str:
if not ts:
return "unlimited"
return "безлимит"
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"
return "безлимит"
current = now_ts() if now is None else now
if expire <= current:
return "expired"
return "истёк"
days = (expire - current) / SECONDS_PER_DAY
return f"{days:.1f} days"
return f"{days:.1f} дн."