Files
malenia/misc/utils.py
hexdev 287f8eebda feat: add subscription and transaction repositories
- Implemented SubscriptionRepository with methods to get, create, and update subscriptions.
- Added BalanceTXRepository for creating and retrieving balance transactions.
- Introduced a test script for simulating Pally webhook for local testing.
2026-04-27 21:33:25 +07:00

236 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import math
import urllib.parse
from datetime import UTC, datetime, timedelta
from random import choice
from bot import texts
from config import settings
from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan
QUOTES: list[str] = [
"Верните себе свободный интернет.",
"Свобода должна быть доступной.",
"Интернет без границ — не привилегия, а норма.",
"Ваш трафик. Ваши правила.",
"Тишина в сети — это тоже выбор.",
"Открытый мир начинается здесь.",
"Без цензуры. Без компромиссов.",
"Простота — лучшая форма свободы.",
"Один клик между вами и миром.",
"Свобода в форме привычки.",
"Будьте везде. Оставайтесь невидимым.",
"Приватность — это право, а не роскошь.",
"Интернет таким, каким он задумывался.",
"Ваши данные — только ваше дело.",
"Закрытых дверей больше не существует.",
"Любой сайт. Из любой точки мира.",
"Оставьте след в истории, а не в браузере.",
"Ключ к открытой сети.",
"Фильтры придумали они. Решение выбрали вы.",
"Безопасность, которую вы не замечаете.",
"Сотрите цифровые границы. Останьтесь собой.",
"Скорость без компромиссов. Сеть без преград.",
"Включили и забыли. Вы в безопасности.",
"Блокировки иллюзорны. Свобода реальна.",
"Никакой слежки. Только вы и интернет.",
"Доступ к информации — ваше базовое право.",
"Интернет снова ваш.",
"Контроль над сетью в ваших руках.",
"Невидимый щит для вашей свободы.",
"Весь мир на экране. Без лишних вопросов.",
]
def fetch_quote() -> str:
return choice(QUOTES)
def format_bytes(num_bytes: float) -> str:
"""Конвертировать байты в читаемый формат: ГБ / МБ / КБ / Б."""
gb_capacity = 1_073_741_824
mb_capacity = 1_048_576
kb_capacity = 1_024
if num_bytes >= gb_capacity: # >= 1 ГБ
return f"{num_bytes / gb_capacity:.1f} ГБ"
if num_bytes >= mb_capacity: # >= 1 МБ
return f"{num_bytes / mb_capacity:.1f} МБ"
if num_bytes >= kb_capacity: # >= 1 КБ
return f"{num_bytes / kb_capacity:.1f} КБ"
return f"{num_bytes:.0f} Б"
def format_traffic(used: float, limit: int) -> str:
"""
Форматировать трафик.
limit == 0 → без ограничений: "X ГБ"
limit > 0 → с лимитом: "X ГБ / Y ГБ"
"""
used_str = format_bytes(used)
if limit == 0:
return texts.TRAFFIC_NO_LIMIT.format(used=used_str)
limit_str = format_bytes(float(limit))
return texts.TRAFFIC_WITH_LIMIT.format(used=used_str, limit=limit_str)
def format_hwid_limit(limit: int | None) -> str:
"""Форматировать лимит HWID: число или «без ограничений»."""
if limit is None or limit == 0:
return texts.UNLIMITED_HWID
return str(limit)
def format_squads(squads: list[dict]) -> str:
"""Форматировать список Internal Squads или «нет»."""
if not squads:
return texts.NO_SQUADS
return ", ".join(s["name"] for s in squads)
def format_days_left(expire_at: datetime) -> str:
"""
Вернуть строку с количеством оставшихся дней:
- «30 дн.» — если осталось > 0 дней
- «истекает сегодня» — если осталось 0 дней
- «истекла N дн. назад» — если уже истекла
"""
now = datetime.now(tz=UTC)
# Убедимся что expire_at timezone-aware
if expire_at.tzinfo is None:
expire_at = expire_at.replace(tzinfo=UTC)
delta = expire_at - now
days = delta.days # целое, может быть отрицательным
if days > 0:
return texts.DAYS_LEFT_POSITIVE.format(days=days)
if days == 0:
return texts.DAYS_LEFT_TODAY
return texts.DAYS_LEFT_EXPIRED.format(days=abs(days))
def format_status(status: str) -> tuple[str, str]:
"""
Вернуть (иконка, текст) для строкового статуса подписки.
Статус приводится к верхнему регистру перед сравнением.
"""
mapping: dict[str, tuple[str, str]] = {
"ACTIVE": (texts.STATUS_ICON_ACTIVE, texts.STATUS_ACTIVE),
"DISABLED": (texts.STATUS_ICON_DISABLED, texts.STATUS_DISABLED),
"EXPIRED": (texts.STATUS_ICON_EXPIRED, texts.STATUS_EXPIRED),
"LIMITED": (texts.STATUS_ICON_LIMITED, texts.STATUS_LIMITED),
"ON_HOLD": (texts.STATUS_ICON_UNKNOWN, texts.STATUS_UNKNOWN),
}
return mapping.get(status.upper(), (texts.STATUS_ICON_UNKNOWN, texts.STATUS_UNKNOWN))
def format_username(username: str | None) -> str:
"""Форматировать username: «@user» или «нет username»."""
if username:
return "@" + username
return texts.NO_USERNAME
def format_referal_id(referal_id: int | None) -> str:
if referal_id:
return str(referal_id)
return texts.NOTHING_PLACEHOLDER
def convert_duration_to_int(duration: SubscriptionDuration) -> int:
mapping: dict[str, int] = {
SubscriptionDuration.MONTH.value: 1,
SubscriptionDuration.THREE_MONTHS.value: 3,
SubscriptionDuration.SIX_MONTHS.value: 6,
SubscriptionDuration.YEAR.value: 12,
}
return mapping.get(duration.value, 1)
def convert_int_to_duration(value: int) -> SubscriptionDuration:
mapping: dict[int, SubscriptionDuration] = {
1: SubscriptionDuration.MONTH,
3: SubscriptionDuration.THREE_MONTHS,
6: SubscriptionDuration.SIX_MONTHS,
12: SubscriptionDuration.YEAR,
}
return mapping.get(value, SubscriptionDuration.MONTH)
def convert_duration_to_timedelta(duration: SubscriptionDuration) -> timedelta:
month_delta = timedelta(days=30)
mapping: dict[str, timedelta] = {
SubscriptionDuration.MONTH.value: month_delta,
SubscriptionDuration.THREE_MONTHS.value: month_delta * 3,
SubscriptionDuration.SIX_MONTHS.value: month_delta * 6,
SubscriptionDuration.YEAR.value: month_delta * 12,
}
return mapping.get(duration.value, month_delta)
def calculate_price(subscription: SubscriptionPlan):
return math.ceil(
(
(subscription.devices * settings.per_device_cost)
+ (
int(subscription.whitelists) * settings.whitelist_cost
if subscription.devices < settings.whitelist_device_threshold
else 0
)
)
* convert_duration_to_int(subscription.duration)
* subscription.discount
)
def get_discount(months) -> float:
if months <= 0:
return 0
calculated = 5 * (math.log2(months / 3) + 1)
return max(0, calculated)
def format_subscription_link(link: str | None = None):
return f"<code>{link or texts.NOTHING_PLACEHOLDER}</code>"
def format_share_deep_link(link: str):
return f"https://t.me/share/url/?url={urllib.parse.quote_plus(link)}"
def format_support_subscription(
subscription: SubscriptionPlan,
full_name: str,
user_id: int,
balance: int,
username: str | None = None,
referal_id: int | None = None,
) -> str:
username = format_username(username)
whitelist_description = "ВЫКЛ"
if subscription.devices >= settings.whitelist_device_threshold:
whitelist_description = "ВКЛ (Бесплатно)"
elif subscription.whitelists:
whitelist_description = "ВКЛ (Платно)"
return texts.SUPPORT_SUBSCRIPTION.format(
full_name=full_name,
telegram_id=user_id,
username_display=username,
duration=convert_duration_to_int(subscription.duration),
devices=subscription.devices,
whitelist_description=whitelist_description,
price=calculate_price(subscription),
balance=balance,
referal_id=referal_id,
)
def get_subscription_link(short_uuid: str):
return settings.subscription_url + "/" + short_uuid
def build_main_menu_text(link: str, balance: int) -> str:
return texts.MAIN_MENU.format(quote=fetch_quote(), link=link, balance=balance)