diff --git a/config.example.toml b/config.example.toml index 10e9f6c..5cdea0a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -6,9 +6,9 @@ marzban_username = "admin" marzban_password = "replace-me" payment_text = """ -Pay to: +7 XXX XXX XX XX -Amount depends on selected period. -After payment, send screenshot/photo or PDF here. +Оплатите на: +7 XXX XXX XX XX +Сумма зависит от выбранного срока продления. +После оплаты отправьте сюда скриншот/фото или PDF с подтверждением. """ database_path = "bot.sqlite3" diff --git a/tests/test_db.py b/tests/test_db.py index 9f28e0a..8356fca 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -66,6 +66,6 @@ def test_used_invite_cannot_be_activated_twice(tmp_path: Path): activated_at=1200, ) except ValueError as exc: - assert "already used" in str(exc) + assert "уже использована" in str(exc) else: raise AssertionError("second activation should fail") diff --git a/vpn_bot/db.py b/vpn_bot/db.py index bb2d63f..1e87997 100644 --- a/vpn_bot/db.py +++ b/vpn_bot/db.py @@ -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 diff --git a/vpn_bot/handlers.py b/vpn_bot/handlers.py index 39d8f2b..dac4915 100644 --- a/vpn_bot/handlers.py +++ b/vpn_bot/handlers.py @@ -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: diff --git a/vpn_bot/keyboards.py b/vpn_bot/keyboards.py index 675e51a..0f1017f 100644 --- a/vpn_bot/keyboards.py +++ b/vpn_bot/keyboards.py @@ -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")], ] ) diff --git a/vpn_bot/marzban.py b/vpn_bot/marzban.py index 2ca8cf5..0360893 100644 --- a/vpn_bot/marzban.py +++ b/vpn_bot/marzban.py @@ -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() diff --git a/vpn_bot/messages.py b/vpn_bot/messages.py index bfb5b37..7b49811 100644 --- a/vpn_bot/messages.py +++ b/vpn_bot/messages.py @@ -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) diff --git a/vpn_bot/utils.py b/vpn_bot/utils.py index dd6e171..973d23c 100644 --- a/vpn_bot/utils.py +++ b/vpn_bot/utils.py @@ -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} дн."