translate to rus
This commit is contained in:
+3
-3
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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} дн."
|
||||||
|
|||||||
Reference in New Issue
Block a user