183 lines
6.2 KiB
Python
183 lines
6.2 KiB
Python
from __future__ import annotations
|
||
|
||
import html
|
||
import math
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
|
||
def format_bytes(value: int | float | None) -> str:
|
||
if value is None or value <= 0:
|
||
return "0 B"
|
||
|
||
units = ["B", "KB", "MB", "GB", "TB", "PB"]
|
||
size = float(value)
|
||
unit_index = 0
|
||
|
||
while size >= 1024 and unit_index < len(units) - 1:
|
||
size /= 1024
|
||
unit_index += 1
|
||
|
||
if unit_index == 0:
|
||
return f"{int(size)} {units[unit_index]}"
|
||
return f"{size:.2f} {units[unit_index]}"
|
||
|
||
|
||
def format_datetime(value: datetime | None) -> str:
|
||
if value is None:
|
||
return "не указано"
|
||
return value.strftime("%d.%m.%Y %H:%M")
|
||
|
||
|
||
def format_traffic_limit(limit_bytes: int | None) -> str:
|
||
if limit_bytes is None or limit_bytes <= 0:
|
||
return "без лимита"
|
||
return format_bytes(limit_bytes)
|
||
|
||
|
||
def _format_days_word(value: int) -> str:
|
||
remainder_100 = value % 100
|
||
remainder_10 = value % 10
|
||
if 11 <= remainder_100 <= 14:
|
||
return "дней"
|
||
if remainder_10 == 1:
|
||
return "день"
|
||
if 2 <= remainder_10 <= 4:
|
||
return "дня"
|
||
return "дней"
|
||
|
||
|
||
def format_datetime_with_days_left(value: datetime | None) -> str:
|
||
if value is None:
|
||
return "не указано"
|
||
|
||
now = datetime.now(value.tzinfo) if value.tzinfo is not None else datetime.now()
|
||
seconds_left = (value - now).total_seconds()
|
||
if seconds_left <= 0:
|
||
return f"{format_datetime(value)} (истёк)"
|
||
|
||
days_left = max(1, math.ceil(seconds_left / 86400))
|
||
return f"{format_datetime(value)} ({days_left} {_format_days_word(days_left)})"
|
||
|
||
|
||
def build_help_text(*, is_admin: bool) -> str:
|
||
lines = [
|
||
"<b>Команды</b>",
|
||
"/start - открыть главное меню",
|
||
"",
|
||
"Дальше используйте кнопки внутри интерфейса бота.",
|
||
]
|
||
return "\n".join(lines)
|
||
|
||
|
||
def format_payment_plan(
|
||
*,
|
||
title: str,
|
||
description: str,
|
||
price_stars: int,
|
||
duration_days: int,
|
||
traffic_limit_bytes: int,
|
||
) -> str:
|
||
lines = [
|
||
"<b>Тариф</b>",
|
||
"",
|
||
f"<b>{html.escape(title)}</b>",
|
||
html.escape(description),
|
||
"",
|
||
f"Срок: <b>{duration_days} дн.</b>",
|
||
f"Трафик: <b>{format_traffic_limit(traffic_limit_bytes)}</b>",
|
||
f"Цена: <b>{price_stars} ₽</b>",
|
||
]
|
||
return "\n".join(lines)
|
||
|
||
|
||
def format_issued_access(
|
||
*,
|
||
subscription_url: str,
|
||
expire_at: datetime,
|
||
traffic_limit_bytes: int,
|
||
brand_name: str = "",
|
||
is_renewal: bool = False,
|
||
) -> str:
|
||
normalized_brand_name = html.escape(brand_name.strip()) if brand_name.strip() else "VPN"
|
||
title = (
|
||
f"<b>🔥 {normalized_brand_name} — подписка продлена</b>"
|
||
if is_renewal
|
||
else f"<b>🔥 {normalized_brand_name} — доступ готов</b>"
|
||
)
|
||
status_text = (
|
||
"Подписка продлена. Откройте её кнопкой ниже "
|
||
"или импортируйте ссылку вручную в ваш VPN-клиент."
|
||
if is_renewal
|
||
else "Подписка активирована. Откройте её кнопкой ниже "
|
||
"или импортируйте ссылку вручную в ваш VPN-клиент."
|
||
)
|
||
lines = [
|
||
title,
|
||
"",
|
||
f"<blockquote>{status_text}</blockquote>",
|
||
"",
|
||
"<b>🔗 Ключ / подписка</b>",
|
||
html.escape(subscription_url),
|
||
"",
|
||
(
|
||
"<blockquote>"
|
||
f"⏳ Действует до: <b>{format_datetime_with_days_left(expire_at)}</b>\n"
|
||
f"📶 Трафик: <b>{format_traffic_limit(traffic_limit_bytes)}</b>"
|
||
"</blockquote>"
|
||
),
|
||
"",
|
||
"<b>📲 Что делать дальше</b>",
|
||
"1. Нажмите кнопку «Открыть подписку» ниже или откройте ссылку выше.",
|
||
"2. Импортируйте подписку или ключ в приложение VPN-клиента.",
|
||
"3. Управлять доступом и продлевать его можно в панели бота.",
|
||
"",
|
||
"<i>Если ссылка не открывается автоматически, скопируйте её в клиент вручную.</i>",
|
||
]
|
||
return "\n".join(lines)
|
||
|
||
|
||
def format_user_card(
|
||
*,
|
||
user: Any,
|
||
squad_names: list[str],
|
||
recent_requests: list[Any] | None = None,
|
||
) -> str:
|
||
lines = [
|
||
f"<b>{html.escape(user.username)}</b> <code>{html.escape(user.status)}</code>",
|
||
f"UUID: <code>{html.escape(user.rw_uuid)}</code>",
|
||
f"Short UUID: <code>{html.escape(user.short_uuid)}</code>",
|
||
f"Telegram ID: <code>{user.telegram_id if user.telegram_id is not None else '-'}</code>",
|
||
f"Истекает: <b>{format_datetime(user.expire_at)}</b>",
|
||
(
|
||
"Трафик: "
|
||
f"<b>{format_bytes(user.used_traffic_bytes)}</b> / "
|
||
f"<b>{format_traffic_limit(user.traffic_limit_bytes)}</b>"
|
||
),
|
||
f"За все время: <b>{format_bytes(user.lifetime_used_traffic_bytes)}</b>",
|
||
f"Последний UA: {html.escape(user.sub_last_user_agent or 'неизвестно')}",
|
||
f"Последнее открытие подписки: {format_datetime(user.sub_last_opened_at)}",
|
||
f"Подписка: {html.escape(user.subscription_url)}",
|
||
]
|
||
|
||
if user.email:
|
||
lines.append(f"Email: {html.escape(user.email)}")
|
||
if user.tag:
|
||
lines.append(f"Tag: <code>{html.escape(user.tag)}</code>")
|
||
if user.description:
|
||
lines.append(f"Описание: {html.escape(user.description)}")
|
||
if squad_names:
|
||
lines.append(f"Squads: {html.escape(', '.join(sorted(squad_names)))}")
|
||
|
||
if recent_requests:
|
||
lines.append("")
|
||
lines.append("<b>Последние запросы подписки</b>")
|
||
for request in recent_requests:
|
||
lines.append(
|
||
f"- {format_datetime(request.request_at)} | "
|
||
f"IP: {html.escape(request.request_ip or '-')} | "
|
||
f"UA: {html.escape((request.user_agent or '-')[:80])}"
|
||
)
|
||
|
||
return "\n".join(lines)
|