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
+3 -3
View File
@@ -6,9 +6,9 @@ marzban_username = "admin"
marzban_password = "replace-me" marzban_password = "replace-me"
payment_text = """ payment_text = """
Pay to: +7 XXX XXX XX XX Оплатите на: +7 XXX XXX XX XX
Amount depends on selected period. Сумма зависит от выбранного срока продления.
After payment, send screenshot/photo or PDF here. После оплаты отправьте сюда скриншот/фото или PDF с подтверждением.
""" """
database_path = "bot.sqlite3" database_path = "bot.sqlite3"
+1 -1
View File
@@ -66,6 +66,6 @@ def test_used_invite_cannot_be_activated_twice(tmp_path: Path):
activated_at=1200, activated_at=1200,
) )
except ValueError as exc: except ValueError as exc:
assert "already used" in str(exc) assert "уже использована" in str(exc)
else: else:
raise AssertionError("second activation should fail") raise AssertionError("second activation should fail")
+2 -2
View File
@@ -76,9 +76,9 @@ class Database:
with self.connect() as conn: with self.connect() as conn:
row = conn.execute("SELECT * FROM users WHERE invite_token = ?", (token,)).fetchone() row = conn.execute("SELECT * FROM users WHERE invite_token = ?", (token,)).fetchone()
if row is None: if row is None:
raise ValueError("invite token not found") raise ValueError("ссылка-приглашение не найдена")
if row["tg_user_id"] is not None: if row["tg_user_id"] is not None:
raise ValueError("invite token already used") raise ValueError("ссылка-приглашение уже использована")
conn.execute( conn.execute(
""" """
UPDATE users UPDATE users
+46 -46
View File
@@ -35,11 +35,11 @@ class BotApp:
def _register_handlers(self) -> None: def _register_handlers(self) -> None:
self.router.message(CommandStart())(self.start) 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 == "/new_user")(self.new_invite)
self.router.message(F.text == "📄 VPN info")(self.vpn_info) self.router.message(F.text == "📄 Информация о VPN")(self.vpn_info)
self.router.message(F.text == "💳 Extend VPN")(self.extend_menu) self.router.message(F.text == "💳 Продлить VPN")(self.extend_menu)
self.router.message(F.text == "🔄 Reset traffic")(self.reset_explain) self.router.message(F.text == "🔄 Сбросить трафик")(self.reset_explain)
self.router.message(F.contact)(self.save_contact) 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.startswith("extend:"))(self.select_extend)
self.router.callback_query(F.data == "reset:confirm")(self.confirm_reset) self.router.callback_query(F.data == "reset:confirm")(self.confirm_reset)
@@ -55,26 +55,26 @@ class BotApp:
return return
if self.is_admin(user_id): if self.is_admin(user_id):
await message.answer("Admin menu", reply_markup=admin_menu()) await message.answer("Меню администратора", reply_markup=admin_menu())
return return
if user_id and self.db.get_by_tg_user_id(user_id): 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 return
await message.answer("Ask an admin for an invite link.") await message.answer("Попросите администратора выдать ссылку-приглашение.")
async def new_invite(self, message: Message) -> None: async def new_invite(self, message: Message) -> None:
if not self.is_admin(message.from_user.id if message.from_user else 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 return
bot_user = await message.bot.get_me() bot_user = await message.bot.get_me()
token = generate_invite_token() token = generate_invite_token()
self.db.create_invite(token=token, admin_id=message.from_user.id, created_at=now_ts()) 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}" 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 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: async def activate_invite(self, message: Message, token: str) -> None:
tg = message.from_user tg = message.from_user
@@ -82,14 +82,14 @@ class BotApp:
return return
existing = self.db.get_by_tg_user_id(tg.id) existing = self.db.get_by_tg_user_id(tg.id)
if existing: if existing:
await message.answer("You are already activated.", reply_markup=main_menu()) await message.answer("Вы уже активированы.", reply_markup=main_menu())
return return
invite = self.db.get_by_token(token) invite = self.db.get_by_token(token)
if invite is None: if invite is None:
await message.answer("Invalid invite link.") await message.answer("Некорректная ссылка-приглашение.")
return return
if invite["tg_user_id"] is not None: if invite["tg_user_id"] is not None:
await message.answer("This invite link was already used.") await message.answer("Эта ссылка-приглашение уже использована.")
return return
marzban_username = format_marzban_username(tg.id) marzban_username = format_marzban_username(tg.id)
@@ -112,15 +112,15 @@ class BotApp:
) )
user = await self.marzban.get_user(marzban_username) user = await self.marzban.get_user(marzban_username)
except (MarzbanError, ValueError) as exc: except (MarzbanError, ValueError) as exc:
await message.answer(f"Activation failed: {exc}") await message.answer(f"Не удалось активировать VPN: {exc}")
await self.notify_admins(message.bot, f"⚠️ Activation failed for {tg.id}: {exc}") await self.notify_admins(message.bot, f"⚠️ Ошибка активации для {tg.id}: {exc}")
return 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 message.answer(format_vpn_info(user))
await self.notify_admins( await self.notify_admins(
message.bot, 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: 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: async def vpn_info(self, message: Message) -> None:
row = await self.get_current_local_user(message) row = await self.get_current_local_user(message)
if not row or not row.get("marzban_username"): 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 return
try: try:
user = await self.marzban.get_user(row["marzban_username"]) user = await self.marzban.get_user(row["marzban_username"])
except MarzbanError as exc: except MarzbanError as exc:
await message.answer(f"Could not fetch VPN info: {exc}") await message.answer(f"Не удалось получить информацию о VPN: {exc}")
return return
await message.answer(format_vpn_info(user)) await message.answer(format_vpn_info(user))
async def extend_menu(self, message: Message) -> None: async def extend_menu(self, message: Message) -> None:
row = await self.get_current_local_user(message) row = await self.get_current_local_user(message)
if not row: if not row:
await message.answer("You are not activated. Use an invite link first.") await message.answer("Вы не активированы. Сначала откройте ссылку-приглашение.")
return 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: async def select_extend(self, query: CallbackQuery) -> None:
if query.from_user is None: if query.from_user is None:
return return
row = self.db.get_by_tg_user_id(query.from_user.id) row = self.db.get_by_tg_user_id(query.from_user.id)
if not row: if not row:
await query.message.answer("You are not activated. Use an invite link first.") await query.message.answer("Вы не активированы. Сначала откройте ссылку-приглашение.")
await query.answer() await query.answer()
return return
months = int(query.data.split(":", 1)[1]) months = int(query.data.split(":", 1)[1])
amount = payment_amount(months) amount = payment_amount(months)
self.pending_payments[query.from_user.id] = PendingPayment(months=months, amount=amount) self.pending_payments[query.from_user.id] = PendingPayment(months=months, amount=amount)
await query.message.answer( 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" f"{self.config.payment_text}\n\n"
"After payment, send a screenshot/photo or file here." "После оплаты пришлите сюда скриншот/фото или файл с подтверждением."
) )
await query.answer() await query.answer()
@@ -174,68 +174,68 @@ class BotApp:
return return
row = self.db.get_by_tg_user_id(message.from_user.id) row = self.db.get_by_tg_user_id(message.from_user.id)
if not row or not row.get("marzban_username"): 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 return
try: try:
user = await self.marzban.extend_user(row["marzban_username"], pending.months) user = await self.marzban.extend_user(row["marzban_username"], pending.months)
except MarzbanError as exc: except MarzbanError as exc:
await message.answer(f"Could not extend VPN: {exc}") await message.answer(f"Не удалось продлить VPN: {exc}")
await self.notify_admins(message.bot, f"⚠️ Extension failed for {row['marzban_username']}: {exc}") await self.notify_admins(message.bot, f"⚠️ Ошибка продления для {row['marzban_username']}: {exc}")
return return
self.pending_payments.pop(message.from_user.id, None) 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)) await message.answer(format_vpn_info(user))
caption = ( caption = (
"💳 Payment proof received and VPN extended\n" "💳 Получено подтверждение оплаты, VPN продлён\n"
f"User: {user_display_name(row)}\n" f"Пользователь: {user_display_name(row)}\n"
f"Marzban: {row['marzban_username']}\n" f"Marzban: {row['marzban_username']}\n"
f"Period: {pending.months} month(s)\n" f"Период: {pending.months} мес.\n"
f"Amount: {pending.amount}\n" f"Сумма: {pending.amount}\n"
f"New expiration: {format_timestamp(user.get('expire'))}" f"Новая дата окончания: {format_timestamp(user.get('expire'))}"
) )
await self.forward_payment_to_admins(message, caption) await self.forward_payment_to_admins(message, caption)
async def reset_explain(self, message: Message) -> None: async def reset_explain(self, message: Message) -> None:
row = await self.get_current_local_user(message) row = await self.get_current_local_user(message)
if not row: if not row:
await message.answer("You are not activated. Use an invite link first.") await message.answer("Вы не активированы. Сначала откройте ссылку-приглашение.")
return return
await message.answer( await message.answer(
"Reset traffic will restore your traffic limit to 50 GB.\n\n" "Сброс трафика восстановит лимит до 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" "Оставшееся время VPN будет округлено вниз до целых периодов по 30 дней.\n\n"
"Example: if you have 1 month and 12 days left, after reset you will have exactly 1 month left.\n\n" "Пример: если осталось 1 месяц и 12 дней, после сброса останется ровно 1 месяц.\n\n"
"Do you want to continue?", "Продолжить?",
reply_markup=reset_confirm_keyboard(), reply_markup=reset_confirm_keyboard(),
) )
async def confirm_reset(self, query: CallbackQuery) -> None: async def confirm_reset(self, query: CallbackQuery) -> None:
row = self.db.get_by_tg_user_id(query.from_user.id) row = self.db.get_by_tg_user_id(query.from_user.id)
if not row or not row.get("marzban_username"): 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() await query.answer()
return return
try: try:
user, full_months, _new_expire = await self.marzban.reset_paid_traffic_with_time_penalty(row["marzban_username"]) user, full_months, _new_expire = await self.marzban.reset_paid_traffic_with_time_penalty(row["marzban_username"])
except MarzbanError as exc: except MarzbanError as exc:
await query.message.answer(f"Could not reset traffic: {exc}") await query.message.answer(f"Не удалось сбросить трафик: {exc}")
await query.answer() await query.answer()
return return
await query.message.answer( 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.message.answer(format_vpn_info(user))
await query.answer() await query.answer()
async def cancel_reset(self, query: CallbackQuery) -> None: async def cancel_reset(self, query: CallbackQuery) -> None:
await query.message.answer("Reset cancelled.") await query.message.answer("Сброс отменён.")
await query.answer() await query.answer()
async def save_contact(self, message: Message) -> None: async def save_contact(self, message: Message) -> None:
if not message.from_user or not message.contact: if not message.from_user or not message.contact:
return return
if message.contact.user_id and message.contact.user_id != message.from_user.id: 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 return
phone = message.contact.phone_number phone = message.contact.phone_number
self.db.update_phone(tg_user_id=message.from_user.id, phone=phone) self.db.update_phone(tg_user_id=message.from_user.id, phone=phone)
@@ -250,9 +250,9 @@ class BotApp:
try: try:
await self.marzban.modify_user(row["marzban_username"], {"note": note}) await self.marzban.modify_user(row["marzban_username"], {"note": note})
except MarzbanError: except MarzbanError:
await message.answer("Phone saved locally, but I could not update Marzban note now.") await message.answer("Телефон сохранён локально, но сейчас не удалось обновить заметку в Marzban.")
return 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: async def notify_admins(self, bot: Bot, text: str) -> None:
for admin_id in self.config.admin_ids: 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: def main_menu() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup( return ReplyKeyboardMarkup(
keyboard=[ keyboard=[
[KeyboardButton(text="📄 VPN info")], [KeyboardButton(text="📄 Информация о VPN")],
[KeyboardButton(text="💳 Extend VPN"), KeyboardButton(text="🔄 Reset traffic")], [KeyboardButton(text="💳 Продлить VPN"), KeyboardButton(text="🔄 Сбросить трафик")],
[KeyboardButton(text="📱 Share phone", request_contact=True)], [KeyboardButton(text="📱 Поделиться телефоном", request_contact=True)],
], ],
resize_keyboard=True, resize_keyboard=True,
) )
@@ -16,7 +16,7 @@ def main_menu() -> ReplyKeyboardMarkup:
def admin_menu() -> ReplyKeyboardMarkup: def admin_menu() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup( return ReplyKeyboardMarkup(
keyboard=[[KeyboardButton(text=" New invite")]], keyboard=[[KeyboardButton(text=" Новое приглашение")]],
resize_keyboard=True, resize_keyboard=True,
) )
@@ -24,9 +24,9 @@ def admin_menu() -> ReplyKeyboardMarkup:
def extend_keyboard() -> InlineKeyboardMarkup: def extend_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup( return InlineKeyboardMarkup(
inline_keyboard=[ inline_keyboard=[
[InlineKeyboardButton(text="1 month — 50 ₽", callback_data="extend:1")], [InlineKeyboardButton(text="1 месяц — 50 ₽", callback_data="extend:1")],
[InlineKeyboardButton(text="2 months — 100 ₽", callback_data="extend:2")], [InlineKeyboardButton(text="2 месяца — 100 ₽", callback_data="extend:2")],
[InlineKeyboardButton(text="3 months — 150 ₽", callback_data="extend:3")], [InlineKeyboardButton(text="3 месяца — 150 ₽", callback_data="extend:3")],
] ]
) )
@@ -34,7 +34,7 @@ def extend_keyboard() -> InlineKeyboardMarkup:
def reset_confirm_keyboard() -> InlineKeyboardMarkup: def reset_confirm_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup( return InlineKeyboardMarkup(
inline_keyboard=[ inline_keyboard=[
[InlineKeyboardButton(text="Confirm reset", callback_data="reset:confirm")], [InlineKeyboardButton(text="Подтвердить сброс", callback_data="reset:confirm")],
[InlineKeyboardButton(text="Cancel", callback_data="reset:cancel")], [InlineKeyboardButton(text="Отмена", callback_data="reset:cancel")],
] ]
) )
+3 -3
View File
@@ -70,11 +70,11 @@ class MarzbanClient:
headers={"Content-Type": "application/x-www-form-urlencoded"}, headers={"Content-Type": "application/x-www-form-urlencoded"},
) )
if response.status_code >= 400: 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() payload = response.json()
token = payload.get("access_token") token = payload.get("access_token")
if not token: if not token:
raise MarzbanError("Marzban auth response does not contain access_token") raise MarzbanError("ответ авторизации Marzban не содержит access_token")
self._token = str(token) self._token = str(token)
return self._token return self._token
@@ -89,7 +89,7 @@ class MarzbanClient:
headers["Authorization"] = f"Bearer {self._token}" headers["Authorization"] = f"Bearer {self._token}"
response = await self._client.request(method, path, headers=headers, **kwargs) response = await self._client.request(method, path, headers=headers, **kwargs)
if response.status_code >= 400: 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: if not response.content:
return None return None
return response.json() 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 from .utils import bytes_to_human, format_remaining_days, format_timestamp
STATUS_LABELS = {
"active": "активен",
"disabled": "отключён",
"limited": "лимит исчерпан",
"expired": "истёк",
"on_hold": "на удержании",
}
def generate_invite_token() -> str: def generate_invite_token() -> str:
return secrets.token_urlsafe(24) 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}" username = username if username.startswith("@") else f"@{username}"
if name and username: if name and username:
return f"{name} ({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: def format_vpn_info(user: dict[str, Any]) -> str:
links = user.get("links") or [] links = user.get("links") or []
subscription_url = user.get("subscription_url") subscription_url = user.get("subscription_url")
status = user.get("status", "неизвестно")
status_text = STATUS_LABELS.get(status, status)
lines = [ lines = [
"📄 VPN info", "📄 Информация о VPN",
"", "",
f"Username: {user.get('username', 'unknown')}", f"Пользователь: {user.get('username', 'неизвестно')}",
f"Status: {user.get('status', 'unknown')}", f"Статус: {status_text}",
f"Expires: {format_timestamp(user.get('expire'))}", f"Действует до: {format_timestamp(user.get('expire'))}",
f"Remaining: {format_remaining_days(user.get('expire'))}", f"Осталось: {format_remaining_days(user.get('expire'))}",
f"Traffic: {bytes_to_human(user.get('used_traffic'))} / {bytes_to_human(user.get('data_limit'))}", f"Трафик: {bytes_to_human(user.get('used_traffic'))} / {bytes_to_human(user.get('data_limit'))}",
] ]
if subscription_url: if subscription_url:
lines.extend(["", "Subscription URL:", str(subscription_url)]) lines.extend(["", "Ссылка подписки:", str(subscription_url)])
if links: if links:
lines.append("") lines.append("")
lines.append("Config links:") lines.append("Ссылки конфигурации:")
lines.extend(str(link) for link in links) lines.extend(str(link) for link in links)
return "\n".join(lines) return "\n".join(lines)
+6 -6
View File
@@ -58,23 +58,23 @@ def build_marzban_note(
def bytes_to_human(value: int | None) -> str: def bytes_to_human(value: int | None) -> str:
if value is None: if value is None:
return "unlimited" return "безлимит"
if value == 0: if value == 0:
return "unlimited" return "безлимит"
return f"{value / GIB:.2f} GB" return f"{value / GIB:.2f} GB"
def format_timestamp(ts: int | None) -> str: def format_timestamp(ts: int | None) -> str:
if not ts: if not ts:
return "unlimited" return "безлимит"
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC") 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: def format_remaining_days(expire: int | None, *, now: int | None = None) -> str:
if not expire: if not expire:
return "unlimited" return "безлимит"
current = now_ts() if now is None else now current = now_ts() if now is None else now
if expire <= current: if expire <= current:
return "expired" return "истёк"
days = (expire - current) / SECONDS_PER_DAY days = (expire - current) / SECONDS_PER_DAY
return f"{days:.1f} days" return f"{days:.1f} дн."