v2.0
This commit is contained in:
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Application package."""
|
||||
1
app/bot/__init__.py
Normal file
1
app/bot/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Bot package."""
|
||||
1
app/bot/handlers/__init__.py
Normal file
1
app/bot/handlers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Bot handlers package."""
|
||||
2499
app/bot/handlers/main.py
Normal file
2499
app/bot/handlers/main.py
Normal file
File diff suppressed because it is too large
Load Diff
1
app/bot/ui/__init__.py
Normal file
1
app/bot/ui/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Telegram bot UI helpers."""
|
||||
595
app/bot/ui/panel.py
Normal file
595
app/bot/ui/panel.py
Normal file
@@ -0,0 +1,595 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from app.services.sync_service import CachedUserView
|
||||
from app.utils.formatters import format_bytes, format_datetime, format_payment_plan, format_traffic_limit
|
||||
|
||||
PANEL_SECTION_HOME = "home"
|
||||
PANEL_SECTION_ADMIN = "admin"
|
||||
PANEL_SECTION_SUBSCRIPTION = "subscription"
|
||||
PANEL_SECTION_REFERRAL = "referral"
|
||||
PANEL_SECTION_SUPPORT = "support"
|
||||
PANEL_SECTION_SUPPORT_TICKET = "support_ticket"
|
||||
PANEL_SECTION_TERMS = "terms"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PanelContext:
|
||||
telegram_id: int
|
||||
display_name: str
|
||||
username: str | None
|
||||
language_code: str | None
|
||||
is_admin: bool
|
||||
users: list[CachedUserView]
|
||||
referral_enabled: bool = True
|
||||
referral_code: str = ""
|
||||
referral_count: int = 0
|
||||
applied_referral_code: str = ""
|
||||
referral_link: str = ""
|
||||
referral_share_url: str = ""
|
||||
referral_recent_names: list[str] | None = None
|
||||
referral_discount_percent: int = 0
|
||||
referral_bonus_days: int = 0
|
||||
admin_total_telegram_users: int = 0
|
||||
admin_total_cached_users: int = 0
|
||||
admin_active_cached_users: int = 0
|
||||
admin_pending_orders: int = 0
|
||||
admin_review_orders: int = 0
|
||||
admin_open_tickets: int = 0
|
||||
admin_pending_referral_bonuses: int = 0
|
||||
payment_enabled: bool = False
|
||||
payment_plan_title: str = ""
|
||||
payment_plan_description: str = ""
|
||||
payment_plan_price_stars: int = 0
|
||||
payment_plan_duration_days: int = 0
|
||||
payment_plan_traffic_limit_bytes: int = 0
|
||||
api_error: str | None = None
|
||||
|
||||
|
||||
def _callback(section: str, *, refresh: bool = False) -> str:
|
||||
return f"panel:{section}:refresh" if refresh else f"panel:{section}"
|
||||
|
||||
|
||||
def _ticket_callback(action: str) -> str:
|
||||
return f"ticket:{action}"
|
||||
|
||||
|
||||
def _referral_callback(action: str) -> str:
|
||||
return f"referral:{action}"
|
||||
|
||||
|
||||
def _payment_callback(action: str) -> str:
|
||||
return f"payment:{action}"
|
||||
|
||||
|
||||
def _admin_callback(action: str) -> str:
|
||||
return f"admin:{action}"
|
||||
|
||||
|
||||
def _status_badge(status: str) -> str:
|
||||
mapping = {
|
||||
"ACTIVE": "🟢 Активна",
|
||||
"LIMITED": "🟡 Ограничена",
|
||||
"DISABLED": "⛔ Отключена",
|
||||
"EXPIRED": "🔴 Истекла",
|
||||
}
|
||||
return mapping.get(status.upper(), f"⚪ {html.escape(status)}")
|
||||
|
||||
|
||||
def _active_users_count(users: list[CachedUserView]) -> int:
|
||||
return sum(1 for user in users if user.record.status.upper() == "ACTIVE")
|
||||
|
||||
def _primary_user(users: list[CachedUserView]) -> CachedUserView | None:
|
||||
for user in users:
|
||||
if user.record.status.upper() == "ACTIVE":
|
||||
return user
|
||||
return users[0] if users else None
|
||||
|
||||
|
||||
def _home_subscription_status(users: list[CachedUserView]) -> str:
|
||||
return "Активна" if _active_users_count(users) > 0 else "Не активна"
|
||||
|
||||
|
||||
def _days_left_text(user: CachedUserView | None) -> str:
|
||||
if user is None or user.record.expire_at is None:
|
||||
return "—"
|
||||
|
||||
seconds_left = (user.record.expire_at - datetime.now()).total_seconds()
|
||||
if seconds_left <= 0:
|
||||
return "0 дней"
|
||||
|
||||
days_left = math.ceil(seconds_left / 86400)
|
||||
return f"{days_left} дней"
|
||||
|
||||
|
||||
def _traffic_summary_text(user: CachedUserView | None) -> str:
|
||||
if user is None:
|
||||
return "—"
|
||||
|
||||
return (
|
||||
f"{format_bytes(user.record.used_traffic_bytes)} / "
|
||||
f"{format_traffic_limit(user.record.traffic_limit_bytes)}"
|
||||
)
|
||||
|
||||
|
||||
def build_panel_keyboard(
|
||||
*,
|
||||
section: str,
|
||||
context: PanelContext,
|
||||
support_url: str = "",
|
||||
terms_url: str = "",
|
||||
payment_review_url: str = "",
|
||||
support_ticket_url: str = "",
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
primary_user = _primary_user(context.users)
|
||||
|
||||
if section == PANEL_SECTION_HOME:
|
||||
has_sub_link = bool(
|
||||
primary_user
|
||||
and primary_user.record.subscription_url.startswith(("http://", "https://"))
|
||||
)
|
||||
if has_sub_link:
|
||||
builder.button(
|
||||
text="🔗 Открыть подписку",
|
||||
url=primary_user.record.subscription_url,
|
||||
)
|
||||
builder.button(
|
||||
text="💳 Подписка",
|
||||
callback_data=_payment_callback("menu"),
|
||||
)
|
||||
if context.is_admin:
|
||||
builder.button(
|
||||
text="🛠 Админ-панель",
|
||||
callback_data=_callback(PANEL_SECTION_ADMIN),
|
||||
)
|
||||
if context.referral_enabled:
|
||||
builder.button(
|
||||
text="👥 Реферальная система",
|
||||
callback_data=_callback(PANEL_SECTION_REFERRAL),
|
||||
)
|
||||
builder.button(
|
||||
text="🆘 Поддержка",
|
||||
callback_data=_callback(PANEL_SECTION_SUPPORT),
|
||||
)
|
||||
if terms_url:
|
||||
builder.button(text="📜 Условия использования", url=terms_url)
|
||||
else:
|
||||
builder.button(
|
||||
text="📜 Условия использования",
|
||||
callback_data=_callback(PANEL_SECTION_TERMS),
|
||||
)
|
||||
if has_sub_link:
|
||||
if context.is_admin and context.referral_enabled:
|
||||
builder.adjust(1, 1, 1, 2, 1)
|
||||
elif context.is_admin:
|
||||
builder.adjust(1, 1, 1, 1, 1)
|
||||
else:
|
||||
if context.referral_enabled:
|
||||
builder.adjust(1, 1, 2, 1)
|
||||
else:
|
||||
builder.adjust(1, 1, 1, 1)
|
||||
else:
|
||||
if context.is_admin and context.referral_enabled:
|
||||
builder.adjust(1, 1, 2, 1)
|
||||
elif context.is_admin:
|
||||
builder.adjust(1, 1, 1, 1)
|
||||
else:
|
||||
if context.referral_enabled:
|
||||
builder.adjust(1, 2, 1)
|
||||
else:
|
||||
builder.adjust(1, 1, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
if section == PANEL_SECTION_ADMIN:
|
||||
if not context.is_admin:
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
builder.button(
|
||||
text="🔍 Найти пользователя",
|
||||
callback_data=_admin_callback("lookup"),
|
||||
)
|
||||
builder.button(
|
||||
text="👤 Пользователи",
|
||||
callback_data=_admin_callback("users"),
|
||||
)
|
||||
builder.button(
|
||||
text="🔄 Полная синхронизация",
|
||||
callback_data=_admin_callback("sync_all"),
|
||||
)
|
||||
builder.button(
|
||||
text="⚙️ Настройки",
|
||||
callback_data=_admin_callback("settings"),
|
||||
)
|
||||
if payment_review_url.strip():
|
||||
builder.button(text="💸 Очередь оплат", url=payment_review_url.strip())
|
||||
if support_ticket_url.strip():
|
||||
builder.button(text="🎫 Тикеты", url=support_ticket_url.strip())
|
||||
builder.button(
|
||||
text="↻ Обновить",
|
||||
callback_data=_callback(PANEL_SECTION_ADMIN, refresh=True),
|
||||
)
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(2, 2, 2, 2)
|
||||
return builder.as_markup()
|
||||
|
||||
if section == PANEL_SECTION_SUBSCRIPTION:
|
||||
if context.payment_enabled:
|
||||
builder.button(
|
||||
text="💳 Продлить подписку" if context.users else "💳 Купить подписку",
|
||||
callback_data=_payment_callback("buy"),
|
||||
)
|
||||
if primary_user and primary_user.record.subscription_url.startswith(("http://", "https://")):
|
||||
builder.button(
|
||||
text="🔗 Открыть подписку",
|
||||
url=primary_user.record.subscription_url,
|
||||
)
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
if section == PANEL_SECTION_REFERRAL:
|
||||
if not context.referral_enabled:
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
if context.referral_share_url:
|
||||
builder.button(
|
||||
text="📨 Поделиться ссылкой",
|
||||
url=context.referral_share_url,
|
||||
)
|
||||
builder.button(
|
||||
text="🎟 Ввести реферальный код",
|
||||
callback_data=_referral_callback("apply"),
|
||||
)
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
if section == PANEL_SECTION_SUPPORT:
|
||||
if support_url.strip():
|
||||
builder.button(text="Написать в поддержку", url=support_url.strip())
|
||||
builder.button(
|
||||
text="🎫 Создать тикет",
|
||||
callback_data=_ticket_callback("create"),
|
||||
)
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
if section == PANEL_SECTION_SUPPORT_TICKET:
|
||||
builder.button(
|
||||
text="🎫 Создать тикет",
|
||||
callback_data=_ticket_callback("create"),
|
||||
)
|
||||
builder.button(text="🆘 Поддержка", callback_data=_callback(PANEL_SECTION_SUPPORT))
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(1, 2)
|
||||
return builder.as_markup()
|
||||
|
||||
if section == PANEL_SECTION_TERMS:
|
||||
if terms_url:
|
||||
builder.button(text="📜 Открыть Terms of Use", url=terms_url)
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def render_panel_caption(*, section: str, context: PanelContext, brand_name: str) -> str:
|
||||
if section == PANEL_SECTION_ADMIN:
|
||||
return _render_admin_caption(context=context, brand_name=brand_name)
|
||||
if section == PANEL_SECTION_SUBSCRIPTION:
|
||||
return _render_subscription_caption(context=context)
|
||||
if section == PANEL_SECTION_REFERRAL:
|
||||
return _render_referral_caption(context=context)
|
||||
if section == PANEL_SECTION_SUPPORT:
|
||||
return _render_support_caption()
|
||||
if section == PANEL_SECTION_SUPPORT_TICKET:
|
||||
return _render_support_ticket_caption()
|
||||
if section == PANEL_SECTION_TERMS:
|
||||
return _render_terms_caption()
|
||||
return _render_home_caption(context=context, brand_name=brand_name)
|
||||
|
||||
|
||||
def _render_home_caption(*, context: PanelContext, brand_name: str) -> str:
|
||||
primary_user = _primary_user(context.users)
|
||||
|
||||
sub_lines = [
|
||||
f"Статус: <b>{_home_subscription_status(context.users)}</b>",
|
||||
f"Срок: <b>{_days_left_text(primary_user)}</b>",
|
||||
f"Трафик: <b>{_traffic_summary_text(primary_user)}</b>",
|
||||
]
|
||||
if primary_user and primary_user.record.subscription_url:
|
||||
sub_lines.append(f"Ссылка: <code>{html.escape(primary_user.record.subscription_url)}</code>")
|
||||
|
||||
lines = [
|
||||
f"Добро пожаловать в <b>{html.escape(brand_name)}</b>",
|
||||
"",
|
||||
"<b>Подписка</b>",
|
||||
"<blockquote>" + "\n".join(sub_lines) + "</blockquote>",
|
||||
]
|
||||
|
||||
if context.api_error:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<i>Remnawave временно недоступен. Показаны данные из локального кэша.</i>",
|
||||
]
|
||||
)
|
||||
|
||||
if context.is_admin:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<i>Для управления ботом используйте кнопку «Админ-панель» ниже.</i>",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_admin_caption(*, context: PanelContext, brand_name: str) -> str:
|
||||
if not context.is_admin:
|
||||
return "\n".join(
|
||||
[
|
||||
"<b>🛠 Админ-панель</b>",
|
||||
"",
|
||||
"<blockquote>Недостаточно прав для доступа к этому разделу.</blockquote>",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
f"<b>🛠 Админ-панель {html.escape(brand_name)}</b>",
|
||||
"",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"Пользователей в боте: <b>{context.admin_total_telegram_users}</b>\n"
|
||||
f"Доступов в кэше: <b>{context.admin_total_cached_users}</b>\n"
|
||||
f"Активных доступов: <b>{context.admin_active_cached_users}</b>\n"
|
||||
f"Заказов без чека: <b>{context.admin_pending_orders}</b>\n"
|
||||
f"Чеков на проверке: <b>{context.admin_review_orders}</b>\n"
|
||||
f"Открытых тикетов: <b>{context.admin_open_tickets}</b>\n"
|
||||
f"Бонусов в ожидании: <b>{context.admin_pending_referral_bonuses}</b>"
|
||||
"</blockquote>"
|
||||
),
|
||||
"",
|
||||
"<i>Используйте кнопки ниже для поиска пользователей, синхронизации и перехода в рабочие чаты.</i>",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _render_subscription_caption(*, context: PanelContext) -> str:
|
||||
lines = ["<b>💳 Подписка</b>"]
|
||||
|
||||
if not context.users:
|
||||
if context.payment_enabled:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<blockquote>У вас пока нет активного доступа.</blockquote>",
|
||||
"",
|
||||
format_payment_plan(
|
||||
title=context.payment_plan_title,
|
||||
description=context.payment_plan_description,
|
||||
price_stars=context.payment_plan_price_stars,
|
||||
duration_days=context.payment_plan_duration_days,
|
||||
traffic_limit_bytes=context.payment_plan_traffic_limit_bytes,
|
||||
),
|
||||
"",
|
||||
(
|
||||
"<i>После выбора тарифа бот покажет реквизиты. "
|
||||
"Затем нужно отправить чек, а администратор подтвердит оплату и выдаст доступ.</i>"
|
||||
),
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.extend(
|
||||
[
|
||||
(
|
||||
"<blockquote>"
|
||||
"У вас пока нет активного доступа.\n\n"
|
||||
"Если у вас уже есть подписка, откройте поддержку "
|
||||
"и отправьте short UUID из ссылки подписки."
|
||||
"</blockquote>"
|
||||
),
|
||||
"",
|
||||
"<i>Покупка пока не настроена в .env. Нужны параметры PAYMENT_*.</i>",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.append("<b>Ваши доступы</b>")
|
||||
for index, user in enumerate(context.users[:2], start=1):
|
||||
squad_text = ", ".join(user.internal_squads[:2]) if user.internal_squads else "не назначены"
|
||||
if len(squad_text) > 80:
|
||||
squad_text = f"{squad_text[:77]}..."
|
||||
lines.append(
|
||||
(
|
||||
f"{index}. <b>{html.escape(user.record.username)}</b> — "
|
||||
f"{_status_badge(user.record.status)}\n"
|
||||
"<blockquote>"
|
||||
f"Истекает: <b>{format_datetime(user.record.expire_at)}</b>\n"
|
||||
f"Трафик: <b>{format_bytes(user.record.used_traffic_bytes)}</b> / "
|
||||
f"<b>{format_traffic_limit(user.record.traffic_limit_bytes)}</b>\n"
|
||||
f"Short UUID: <code>{html.escape(user.record.short_uuid)}</code>\n"
|
||||
f"Группы: {html.escape(squad_text)}"
|
||||
"</blockquote>"
|
||||
)
|
||||
)
|
||||
|
||||
if len(context.users) > 2:
|
||||
lines.append(f"<i>И ещё аккаунтов: {len(context.users) - 2}</i>")
|
||||
|
||||
if context.payment_enabled:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
(
|
||||
"<i>"
|
||||
"Чтобы продлить текущую подписку, используйте кнопку "
|
||||
"«Продлить подписку». После подтверждения оплаты бот "
|
||||
"автоматически добавит выбранный срок к вашему доступу."
|
||||
"</i>"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
if context.api_error:
|
||||
lines.append("")
|
||||
lines.append("<i>Последнее обновление пришло из кэша.</i>")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_referral_caption(*, context: PanelContext) -> str:
|
||||
if not context.referral_enabled:
|
||||
return "\n".join(
|
||||
[
|
||||
"<b>👥 Реферальная система</b>",
|
||||
"",
|
||||
"<blockquote>Реферальная система сейчас отключена администратором.</blockquote>",
|
||||
]
|
||||
)
|
||||
|
||||
lines = [
|
||||
"<b>👥 Реферальная система</b>",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"Ваш реферальный код: <code>{html.escape(context.referral_code or '—')}</code>\n"
|
||||
f"Приглашено пользователей: <b>{context.referral_count}</b>"
|
||||
"</blockquote>"
|
||||
),
|
||||
]
|
||||
|
||||
if context.referral_bonus_days > 0:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"🎁 Бонус за подтверждённую оплату по вашему коду: "
|
||||
f"<b>+{context.referral_bonus_days} дней</b>"
|
||||
"</blockquote>"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
if context.applied_referral_code:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<b>Активированная скидка</b>",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"Код: <code>{html.escape(context.applied_referral_code)}</code>\n"
|
||||
f"Скидка перед оплатой: <b>{max(context.referral_discount_percent, 0)}%</b>"
|
||||
"</blockquote>"
|
||||
),
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
(
|
||||
"<i>"
|
||||
f"Чтобы получить скидку {max(context.referral_discount_percent, 0)}%, "
|
||||
"код нужно ввести вручную до оплаты."
|
||||
"</i>"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
if context.referral_link:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<b>Ваша ссылка</b>",
|
||||
f"<blockquote><code>{html.escape(context.referral_link)}</code></blockquote>",
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<i>Для генерации ссылки укажите BOT_PUBLIC_USERNAME в .env.</i>",
|
||||
]
|
||||
)
|
||||
|
||||
if context.referral_recent_names:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<b>Последние приглашения</b>",
|
||||
"<blockquote>" + "\n".join(html.escape(name) for name in context.referral_recent_names) + "</blockquote>",
|
||||
]
|
||||
)
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<i>Переход по ссылке сам по себе не активирует скидку. Код нужно ввести вручную перед оплатой.</i>",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_terms_caption() -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"<b>📜 Правила VPN</b>",
|
||||
(
|
||||
"<blockquote>"
|
||||
"1. Используя сервис и бота, вы подтверждаете согласие с правилами платформы и обязуетесь использовать VPN только в законных целях.\n"
|
||||
"2. VPN предназначен для защиты данных, приватности и безопасного доступа к интернет-ресурсам.\n"
|
||||
"3. Запрещены передача доступа третьим лицам, спам, взлом, мошенничество, распространение вредоносного ПО, атаки и любой незаконный контент.\n"
|
||||
"4. Пользователь самостоятельно несёт ответственность за свои действия при использовании сервиса.\n"
|
||||
"5. Для стабильной работы могут обрабатываться технические данные подключения без передачи третьим лицам, кроме случаев, предусмотренных законодательством.\n"
|
||||
"6. При нарушении правил доступ может быть ограничен или приостановлен без возврата средств.\n\n"
|
||||
"⚠️ VPN повышает уровень конфиденциальности, но не гарантирует полной анонимности."
|
||||
"</blockquote>"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _render_support_caption() -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"<b>🆘 Помощь и сопровождение</b>",
|
||||
(
|
||||
"<blockquote>"
|
||||
"Здесь вы можете обратиться в поддержку по вопросам доступа, оплаты, подключения и работы сервиса.\n\n"
|
||||
"Если нужна помощь, откройте тикет через кнопку ниже."
|
||||
"</blockquote>"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _render_support_ticket_caption() -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"<b>🎫 Создание тикета</b>",
|
||||
(
|
||||
"<blockquote>"
|
||||
"Опишите проблему как можно подробнее: устройство, приложение, текст ошибки и что именно не работает.\n\n"
|
||||
"После нажатия кнопки ниже бот переведёт вас в режим создания тикета, и следующее сообщение будет отправлено в поддержку."
|
||||
"</blockquote>"
|
||||
),
|
||||
]
|
||||
)
|
||||
305
app/config.py
Normal file
305
app/config.py
Normal file
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from urllib.parse import quote_plus, urlparse
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PaymentPlanSettings:
|
||||
code: str
|
||||
days: int
|
||||
amount_rub: int
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
bot_token: str = Field(validation_alias="BOT_TOKEN")
|
||||
bot_admin_ids_raw: str = Field(default="", validation_alias="BOT_ADMIN_IDS")
|
||||
bot_moderator_ids_raw: str = Field(default="", validation_alias="BOT_MODERATOR_IDS")
|
||||
telegram_proxy_url: str = Field(default="", validation_alias="TELEGRAM_PROXY_URL")
|
||||
bot_brand_name: str = Field(default="OREOL VPN", validation_alias="BOT_BRAND_NAME")
|
||||
bot_public_username: str = Field(default="", validation_alias="BOT_PUBLIC_USERNAME")
|
||||
bot_support_url: str = Field(default="", validation_alias="BOT_SUPPORT_URL")
|
||||
bot_terms_url: str = Field(default="", validation_alias="BOT_TERMS_URL")
|
||||
bot_support_ticket_link: str = Field(default="", validation_alias="BOT_SUPPORT_TICKET_LINK")
|
||||
bot_support_ticket_chat_id_raw: str = Field(
|
||||
default="",
|
||||
validation_alias="BOT_SUPPORT_TICKET_CHAT_ID",
|
||||
)
|
||||
bot_support_ticket_thread_id: int = Field(
|
||||
default=0,
|
||||
validation_alias="BOT_SUPPORT_TICKET_THREAD_ID",
|
||||
)
|
||||
bot_start_image_enabled: bool = Field(
|
||||
default=True,
|
||||
validation_alias="BOT_START_IMAGE_ENABLED",
|
||||
)
|
||||
bot_start_image_path: str = Field(default="", validation_alias="BOT_START_IMAGE_PATH")
|
||||
|
||||
remnawave_base_url: str = Field(validation_alias="REMNAWAVE_BASE_URL")
|
||||
remnawave_api_token: str = Field(default="", validation_alias="REMNAWAVE_API_TOKEN")
|
||||
remnawave_caddy_api_key: str = Field(default="", validation_alias="REMNAWAVE_CADDY_API_KEY")
|
||||
remnawave_timeout_seconds: float = Field(
|
||||
default=20.0,
|
||||
validation_alias="REMNAWAVE_TIMEOUT_SECONDS",
|
||||
)
|
||||
|
||||
db_host: str = Field(default="127.0.0.1", validation_alias="DB_HOST")
|
||||
db_port: int = Field(default=3306, validation_alias="DB_PORT")
|
||||
db_name: str = Field(default="telegabot", validation_alias="DB_NAME")
|
||||
db_user: str = Field(default="telegabot", validation_alias="DB_USER")
|
||||
db_password: str = Field(default="", validation_alias="DB_PASSWORD")
|
||||
db_echo: bool = Field(default=False, validation_alias="DB_ECHO")
|
||||
create_database_on_start: bool = Field(
|
||||
default=True,
|
||||
validation_alias="CREATE_DATABASE_ON_START",
|
||||
)
|
||||
create_tables_on_start: bool = Field(
|
||||
default=True,
|
||||
validation_alias="CREATE_TABLES_ON_START",
|
||||
)
|
||||
sync_subscription_history: bool = Field(
|
||||
default=True,
|
||||
validation_alias="SYNC_SUBSCRIPTION_HISTORY",
|
||||
)
|
||||
sync_batch_size: int = Field(default=100, validation_alias="SYNC_BATCH_SIZE")
|
||||
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
|
||||
|
||||
payment_plans_raw: str = Field(
|
||||
default="30:250,180:600,365:1000",
|
||||
validation_alias="PAYMENT_PLANS",
|
||||
)
|
||||
payment_transfer_text: str = Field(
|
||||
default="Переведите оплату по указанным реквизитам и отправьте чек в бот.",
|
||||
validation_alias="PAYMENT_TRANSFER_TEXT",
|
||||
)
|
||||
payment_review_link: str = Field(default="", validation_alias="PAYMENT_REVIEW_LINK")
|
||||
payment_review_chat_id_raw: str = Field(
|
||||
default="",
|
||||
validation_alias="PAYMENT_REVIEW_CHAT_ID",
|
||||
)
|
||||
payment_review_thread_id: int = Field(
|
||||
default=0,
|
||||
validation_alias="PAYMENT_REVIEW_THREAD_ID",
|
||||
)
|
||||
referral_discount_percent: int = Field(
|
||||
default=5,
|
||||
validation_alias="REFERRAL_DISCOUNT_PERCENT",
|
||||
)
|
||||
referral_bonus_days: int = Field(
|
||||
default=7,
|
||||
validation_alias="REFERRAL_BONUS_DAYS",
|
||||
)
|
||||
payment_plan_code: str = Field(default="vpn_30d", validation_alias="PAYMENT_PLAN_CODE")
|
||||
payment_plan_title: str = Field(
|
||||
default="OREOL VPN на 30 дней",
|
||||
validation_alias="PAYMENT_PLAN_TITLE",
|
||||
)
|
||||
payment_plan_description: str = Field(
|
||||
default="Доступ к VPN на 30 дней",
|
||||
validation_alias="PAYMENT_PLAN_DESCRIPTION",
|
||||
)
|
||||
payment_plan_price_stars: int = Field(default=500, validation_alias="PAYMENT_PLAN_PRICE_STARS")
|
||||
payment_plan_duration_days: int = Field(
|
||||
default=30,
|
||||
validation_alias="PAYMENT_PLAN_DURATION_DAYS",
|
||||
)
|
||||
payment_plan_traffic_limit_gb: int = Field(
|
||||
default=0,
|
||||
validation_alias="PAYMENT_PLAN_TRAFFIC_LIMIT_GB",
|
||||
)
|
||||
payment_plan_traffic_reset_period: str = Field(
|
||||
default="NO_RESET",
|
||||
validation_alias="PAYMENT_PLAN_TRAFFIC_RESET_PERIOD",
|
||||
)
|
||||
payment_internal_squad_uuids_raw: str = Field(
|
||||
default="",
|
||||
validation_alias="PAYMENT_INTERNAL_SQUAD_UUIDS",
|
||||
)
|
||||
payment_external_squad_uuid: str = Field(
|
||||
default="",
|
||||
validation_alias="PAYMENT_EXTERNAL_SQUAD_UUID",
|
||||
)
|
||||
payment_support_text: str = Field(
|
||||
default="Если оплата прошла, но доступ не выдался, напишите в поддержку.",
|
||||
validation_alias="PAYMENT_SUPPORT_TEXT",
|
||||
)
|
||||
payment_username_prefix: str = Field(
|
||||
default="Oreol",
|
||||
validation_alias="PAYMENT_USERNAME_PREFIX",
|
||||
)
|
||||
payment_user_tag: str = Field(default="BOT", validation_alias="PAYMENT_USER_TAG")
|
||||
|
||||
@cached_property
|
||||
def bot_admin_ids(self) -> set[int]:
|
||||
values: set[int] = set()
|
||||
for raw_part in self.bot_admin_ids_raw.split(","):
|
||||
part = raw_part.strip()
|
||||
if part:
|
||||
values.add(int(part))
|
||||
return values
|
||||
|
||||
@cached_property
|
||||
def bot_moderator_ids(self) -> set[int]:
|
||||
values: set[int] = set()
|
||||
for raw_part in self.bot_moderator_ids_raw.split(","):
|
||||
part = raw_part.strip()
|
||||
if part:
|
||||
values.add(int(part))
|
||||
return values
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
user = quote_plus(self.db_user)
|
||||
password = quote_plus(self.db_password)
|
||||
return (
|
||||
f"mysql+aiomysql://{user}:{password}"
|
||||
f"@{self.db_host}:{self.db_port}/{self.db_name}?charset=utf8mb4"
|
||||
)
|
||||
|
||||
@property
|
||||
def database_server_url(self) -> str:
|
||||
user = quote_plus(self.db_user)
|
||||
password = quote_plus(self.db_password)
|
||||
return (
|
||||
f"mysql+aiomysql://{user}:{password}"
|
||||
f"@{self.db_host}:{self.db_port}/?charset=utf8mb4"
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def support_ticket_chat_id(self) -> int | None:
|
||||
if self.bot_support_ticket_chat_id_raw.strip():
|
||||
return int(self.bot_support_ticket_chat_id_raw.strip())
|
||||
|
||||
parsed_chat_id, _ = self._parse_private_topic_link(self.bot_support_ticket_link)
|
||||
return parsed_chat_id
|
||||
|
||||
@cached_property
|
||||
def support_ticket_message_thread_id(self) -> int | None:
|
||||
if self.bot_support_ticket_thread_id > 0:
|
||||
return self.bot_support_ticket_thread_id
|
||||
|
||||
_, parsed_thread_id = self._parse_private_topic_link(self.bot_support_ticket_link)
|
||||
return parsed_thread_id
|
||||
|
||||
@cached_property
|
||||
def payment_review_chat_id(self) -> int | None:
|
||||
if self.payment_review_chat_id_raw.strip():
|
||||
return int(self.payment_review_chat_id_raw.strip())
|
||||
|
||||
parsed_chat_id, _ = self._parse_private_topic_link(self.payment_review_link)
|
||||
return parsed_chat_id
|
||||
|
||||
@cached_property
|
||||
def payment_review_message_thread_id(self) -> int | None:
|
||||
if self.payment_review_thread_id > 0:
|
||||
return self.payment_review_thread_id
|
||||
|
||||
_, parsed_thread_id = self._parse_private_topic_link(self.payment_review_link)
|
||||
return parsed_thread_id
|
||||
|
||||
@staticmethod
|
||||
def _parse_private_topic_link(link: str) -> tuple[int | None, int | None]:
|
||||
cleaned = link.strip()
|
||||
if not cleaned:
|
||||
return None, None
|
||||
|
||||
parsed = urlparse(cleaned)
|
||||
if parsed.netloc not in {"t.me", "telegram.me", "www.t.me", "www.telegram.me"}:
|
||||
return None, None
|
||||
|
||||
parts = [part for part in parsed.path.split("/") if part]
|
||||
if len(parts) < 3 or parts[0] != "c" or not parts[1].isdigit():
|
||||
return None, None
|
||||
|
||||
chat_id = int(f"-100{parts[1]}")
|
||||
thread_id = int(parts[2]) if len(parts) >= 4 and parts[2].isdigit() else None
|
||||
return chat_id, thread_id
|
||||
|
||||
def is_admin(self, telegram_id: int | None) -> bool:
|
||||
return telegram_id is not None and telegram_id in self.bot_admin_ids
|
||||
|
||||
def is_moderator(self, telegram_id: int | None) -> bool:
|
||||
return telegram_id is not None and telegram_id in self.bot_moderator_ids
|
||||
|
||||
@property
|
||||
def bot_public_username_normalized(self) -> str:
|
||||
return self.bot_public_username.strip().removeprefix("@")
|
||||
|
||||
@cached_property
|
||||
def payment_internal_squad_uuids(self) -> list[str]:
|
||||
values: list[str] = []
|
||||
for raw_part in self.payment_internal_squad_uuids_raw.split(","):
|
||||
part = raw_part.strip()
|
||||
if part:
|
||||
values.append(part)
|
||||
return values
|
||||
|
||||
@cached_property
|
||||
def payment_external_squad_uuid_normalized(self) -> str | None:
|
||||
value = self.payment_external_squad_uuid.strip()
|
||||
return value or None
|
||||
|
||||
@cached_property
|
||||
def payment_username_prefix_normalized(self) -> str:
|
||||
normalized = "".join(
|
||||
ch for ch in self.payment_username_prefix.strip() if ch.isalnum() or ch in "_-"
|
||||
)
|
||||
return normalized[:12] or "Oreol"
|
||||
|
||||
@cached_property
|
||||
def payment_user_tag_normalized(self) -> str | None:
|
||||
normalized = "".join(
|
||||
ch for ch in self.payment_user_tag.strip().upper() if ch.isalnum() or ch == "_"
|
||||
)
|
||||
return normalized[:16] or None
|
||||
|
||||
@cached_property
|
||||
def payment_plans(self) -> list[PaymentPlanSettings]:
|
||||
values: list[PaymentPlanSettings] = []
|
||||
for raw_part in self.payment_plans_raw.split(","):
|
||||
part = raw_part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
fragments = [fragment.strip() for fragment in part.split(":")]
|
||||
if len(fragments) != 2 or not fragments[0].isdigit() or not fragments[1].isdigit():
|
||||
continue
|
||||
|
||||
days = int(fragments[0])
|
||||
amount_rub = int(fragments[1])
|
||||
if days <= 0 or amount_rub <= 0:
|
||||
continue
|
||||
|
||||
values.append(
|
||||
PaymentPlanSettings(
|
||||
code=f"{days}d",
|
||||
days=days,
|
||||
amount_rub=amount_rub,
|
||||
)
|
||||
)
|
||||
|
||||
if values:
|
||||
return sorted(values, key=lambda item: item.days)
|
||||
|
||||
fallback_amount = max(self.payment_plan_price_stars, 0)
|
||||
fallback_days = max(self.payment_plan_duration_days, 0)
|
||||
if fallback_amount > 0 and fallback_days > 0:
|
||||
return [
|
||||
PaymentPlanSettings(
|
||||
code=self.payment_plan_code.strip() or f"{fallback_days}d",
|
||||
days=fallback_days,
|
||||
amount_rub=fallback_amount,
|
||||
)
|
||||
]
|
||||
|
||||
return []
|
||||
1
app/db/__init__.py
Normal file
1
app/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Database package."""
|
||||
11
app/db/base.py
Normal file
11
app/db/base.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for ORM models."""
|
||||
238
app/db/models.py
Normal file
238
app/db/models.py
Normal file
@@ -0,0 +1,238 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, utcnow
|
||||
|
||||
|
||||
class TelegramUser(Base):
|
||||
__tablename__ = "telegram_users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
|
||||
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
first_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
last_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
language_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class RemnawaveUser(Base):
|
||||
__tablename__ = "remnawave_users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
owner_telegram_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("telegram_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
rw_uuid: Mapped[str] = mapped_column(String(36), nullable=False, unique=True, index=True)
|
||||
rw_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True)
|
||||
short_uuid: Mapped[str] = mapped_column(String(48), nullable=False, unique=True, index=True)
|
||||
username: Mapped[str] = mapped_column(String(36), nullable=False, unique=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
|
||||
traffic_limit_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
used_traffic_bytes: Mapped[float] = mapped_column(Float(asdecimal=False), nullable=False, default=0.0)
|
||||
lifetime_used_traffic_bytes: Mapped[float] = mapped_column(Float(asdecimal=False), nullable=False, default=0.0)
|
||||
expire_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
telegram_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
|
||||
email: Mapped[str | None] = mapped_column(String(320), nullable=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tag: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
||||
hwid_device_limit: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
external_squad_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
|
||||
trojan_password: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
vless_uuid: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
ss_password: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
last_triggered_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
sub_revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
sub_last_user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
sub_last_opened_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_traffic_reset_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
subscription_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
online_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
first_connected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_connected_node_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
|
||||
remote_created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
remote_updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
synced_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
|
||||
|
||||
class InternalSquad(Base):
|
||||
__tablename__ = "internal_squads"
|
||||
|
||||
uuid: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
synced_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
|
||||
|
||||
class RemnawaveUserInternalSquad(Base):
|
||||
__tablename__ = "remnawave_user_internal_squads"
|
||||
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("remnawave_users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
squad_uuid: Mapped[str] = mapped_column(
|
||||
ForeignKey("internal_squads.uuid", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionRequestLog(Base):
|
||||
__tablename__ = "subscription_request_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
remote_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
|
||||
user_uuid: Mapped[str] = mapped_column(
|
||||
ForeignKey("remnawave_users.rw_uuid", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
request_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
request_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
synced_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
|
||||
|
||||
class SupportTicket(Base):
|
||||
__tablename__ = "support_tickets"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_support_tickets_chat_message",
|
||||
"support_chat_id",
|
||||
"support_message_id",
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
public_id: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, index=True)
|
||||
|
||||
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
user_message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
support_chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
support_thread_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
support_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="OPEN")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class ReferralInvite(Base):
|
||||
__tablename__ = "referral_invites"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
inviter_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
invited_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
|
||||
invited_username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
invited_display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
|
||||
|
||||
class ReferralCode(Base):
|
||||
__tablename__ = "referral_codes"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
|
||||
code: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class ReferralBonus(Base):
|
||||
__tablename__ = "referral_bonuses"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
inviter_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
invited_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
source_order_uuid: Mapped[str] = mapped_column(String(36), nullable=False, unique=True, index=True)
|
||||
bonus_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="PENDING", index=True)
|
||||
applied_to_user_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
applied_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class BotSetting(Base):
|
||||
__tablename__ = "bot_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||
value: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
updated_by_telegram_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class SubscriptionNotification(Base):
|
||||
__tablename__ = "subscription_notifications"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_sub_notif_unique",
|
||||
"remnawave_user_id",
|
||||
"notification_type",
|
||||
"expire_at_snapshot",
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
remnawave_user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("remnawave_users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
notification_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
expire_at_snapshot: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
sent_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
|
||||
|
||||
class PaymentOrder(Base):
|
||||
__tablename__ = "payment_orders"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
order_uuid: Mapped[str] = mapped_column(String(36), nullable=False, unique=True, index=True)
|
||||
telegram_user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("telegram_users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
plan_code: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
plan_title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
plan_duration_days: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
traffic_limit_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
traffic_limit_strategy: Mapped[str] = mapped_column(String(32), nullable=False, default="NO_RESET")
|
||||
amount_stars: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(8), nullable=False, default="XTR")
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="PENDING", index=True)
|
||||
invoice_payload: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
|
||||
provision_username: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
remnawave_user_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
subscription_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
telegram_payment_charge_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
|
||||
provider_payment_charge_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
paid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
fulfilled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
152
app/db/session.py
Normal file
152
app/db/session.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncConnection,
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db import models as _models # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PAYMENT_ORDERS_TABLE_NAME = "payment_orders"
|
||||
PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN = "provision_username"
|
||||
PAYMENT_ORDERS_PROVISION_USERNAME_INDEX = "ix_payment_orders_provision_username"
|
||||
|
||||
|
||||
def create_engine_and_session_factory(
|
||||
database_url: str,
|
||||
*,
|
||||
echo: bool = False,
|
||||
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
|
||||
engine = create_async_engine(
|
||||
database_url,
|
||||
echo=echo,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||
return engine, session_factory
|
||||
|
||||
|
||||
def _quote_mysql_identifier(identifier: str) -> str:
|
||||
return f"`{identifier.replace('`', '``')}`"
|
||||
|
||||
|
||||
async def _get_mysql_single_column_indexes(
|
||||
connection: AsyncConnection,
|
||||
*,
|
||||
table_name: str,
|
||||
column_name: str,
|
||||
non_unique: int,
|
||||
) -> list[str]:
|
||||
result = await connection.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT index_name
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = :table_name
|
||||
GROUP BY index_name, non_unique
|
||||
HAVING non_unique = :non_unique
|
||||
AND COUNT(*) = 1
|
||||
AND MAX(CASE WHEN column_name = :column_name THEN 1 ELSE 0 END) = 1
|
||||
"""
|
||||
),
|
||||
{
|
||||
"table_name": table_name,
|
||||
"column_name": column_name,
|
||||
"non_unique": non_unique,
|
||||
},
|
||||
)
|
||||
return [row[0] for row in result]
|
||||
|
||||
|
||||
async def _normalize_payment_order_provision_username_index(
|
||||
connection: AsyncConnection,
|
||||
) -> None:
|
||||
if connection.dialect.name != "mysql":
|
||||
return
|
||||
|
||||
unique_index_names = await _get_mysql_single_column_indexes(
|
||||
connection,
|
||||
table_name=PAYMENT_ORDERS_TABLE_NAME,
|
||||
column_name=PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN,
|
||||
non_unique=0,
|
||||
)
|
||||
|
||||
quoted_table_name = _quote_mysql_identifier(PAYMENT_ORDERS_TABLE_NAME)
|
||||
for index_name in unique_index_names:
|
||||
logger.info(
|
||||
"Dropping stale UNIQUE index %s on %s.%s",
|
||||
index_name,
|
||||
PAYMENT_ORDERS_TABLE_NAME,
|
||||
PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN,
|
||||
)
|
||||
await connection.execute(
|
||||
text(
|
||||
"DROP INDEX "
|
||||
f"{_quote_mysql_identifier(index_name)} "
|
||||
f"ON {quoted_table_name}"
|
||||
)
|
||||
)
|
||||
|
||||
non_unique_index_names = await _get_mysql_single_column_indexes(
|
||||
connection,
|
||||
table_name=PAYMENT_ORDERS_TABLE_NAME,
|
||||
column_name=PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN,
|
||||
non_unique=1,
|
||||
)
|
||||
if non_unique_index_names:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Creating non-unique index %s on %s.%s",
|
||||
PAYMENT_ORDERS_PROVISION_USERNAME_INDEX,
|
||||
PAYMENT_ORDERS_TABLE_NAME,
|
||||
PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN,
|
||||
)
|
||||
await connection.execute(
|
||||
text(
|
||||
"CREATE INDEX "
|
||||
f"{_quote_mysql_identifier(PAYMENT_ORDERS_PROVISION_USERNAME_INDEX)} "
|
||||
f"ON {quoted_table_name} "
|
||||
f"({_quote_mysql_identifier(PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN)})"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def ensure_database_exists(
|
||||
server_url: str,
|
||||
database_name: str,
|
||||
*,
|
||||
echo: bool = False,
|
||||
) -> None:
|
||||
engine = create_async_engine(
|
||||
server_url,
|
||||
echo=echo,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
try:
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(
|
||||
text(
|
||||
"CREATE DATABASE IF NOT EXISTS "
|
||||
f"{_quote_mysql_identifier(database_name)} "
|
||||
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def init_db(engine: AsyncEngine) -> None:
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
await _normalize_payment_order_provision_username_index(connection)
|
||||
135
app/main.py
Normal file
135
app/main.py
Normal file
@@ -0,0 +1,135 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramNetworkError
|
||||
from aiogram.types import BotCommand
|
||||
|
||||
from app.bot.handlers.main import build_router
|
||||
from app.config import Settings
|
||||
from app.db.session import create_engine_and_session_factory, ensure_database_exists, init_db
|
||||
from app.services.bot_config_service import BotConfigService
|
||||
from app.services.notification_service import SubscriptionNotificationService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.remnawave_client import RemnawaveApiClient
|
||||
from app.services.support_ticket_service import SupportTicketService
|
||||
from app.services.sync_service import SyncService
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
settings = Settings()
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
||||
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
engine = None
|
||||
remnawave_client = None
|
||||
bot = None
|
||||
|
||||
try:
|
||||
if settings.create_database_on_start:
|
||||
logger.info(
|
||||
"Ensuring MariaDB database '%s' exists on %s:%s",
|
||||
settings.db_name,
|
||||
settings.db_host,
|
||||
settings.db_port,
|
||||
)
|
||||
await ensure_database_exists(
|
||||
settings.database_server_url,
|
||||
settings.db_name,
|
||||
echo=settings.db_echo,
|
||||
)
|
||||
|
||||
engine, session_factory = create_engine_and_session_factory(
|
||||
settings.database_url,
|
||||
echo=settings.db_echo,
|
||||
)
|
||||
|
||||
if settings.create_tables_on_start:
|
||||
await init_db(engine)
|
||||
|
||||
remnawave_client = RemnawaveApiClient(
|
||||
base_url=settings.remnawave_base_url,
|
||||
api_token=settings.remnawave_api_token,
|
||||
caddy_api_key=settings.remnawave_caddy_api_key,
|
||||
timeout_seconds=settings.remnawave_timeout_seconds,
|
||||
)
|
||||
sync_service = SyncService(
|
||||
settings=settings,
|
||||
session_factory=session_factory,
|
||||
remnawave_client=remnawave_client,
|
||||
)
|
||||
bot_config_service = BotConfigService(
|
||||
settings=settings,
|
||||
session_factory=session_factory,
|
||||
)
|
||||
support_ticket_service = SupportTicketService(session_factory=session_factory)
|
||||
payment_service = PaymentService(
|
||||
settings=settings,
|
||||
session_factory=session_factory,
|
||||
remnawave_client=remnawave_client,
|
||||
sync_service=sync_service,
|
||||
config_service=bot_config_service,
|
||||
)
|
||||
|
||||
bot_session = AiohttpSession(proxy=settings.telegram_proxy_url or None)
|
||||
bot = Bot(
|
||||
token=settings.bot_token,
|
||||
session=bot_session,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
dispatcher = Dispatcher()
|
||||
dispatcher.include_router(
|
||||
build_router(
|
||||
settings=settings,
|
||||
sync_service=sync_service,
|
||||
support_ticket_service=support_ticket_service,
|
||||
payment_service=payment_service,
|
||||
bot_config_service=bot_config_service,
|
||||
)
|
||||
)
|
||||
|
||||
await bot.set_my_commands(
|
||||
[
|
||||
BotCommand(command="start", description="Открыть главное меню"),
|
||||
]
|
||||
)
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
notification_service = SubscriptionNotificationService(
|
||||
session_factory=session_factory,
|
||||
sync_service=sync_service,
|
||||
config_service=bot_config_service,
|
||||
project_root=project_root,
|
||||
)
|
||||
notification_task = asyncio.create_task(
|
||||
notification_service.run_periodic(bot),
|
||||
)
|
||||
|
||||
try:
|
||||
await dispatcher.start_polling(bot)
|
||||
finally:
|
||||
notification_task.cancel()
|
||||
try:
|
||||
await notification_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except TelegramNetworkError:
|
||||
logger.exception(
|
||||
"Cannot reach Telegram API. Check outbound HTTPS access to api.telegram.org:443, DNS, firewall, VPN, or proxy."
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if remnawave_client is not None:
|
||||
await remnawave_client.close()
|
||||
if bot is not None:
|
||||
await bot.session.close()
|
||||
if engine is not None:
|
||||
await engine.dispose()
|
||||
1
app/schemas/__init__.py
Normal file
1
app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Pydantic schemas."""
|
||||
75
app/schemas/remnawave.py
Normal file
75
app/schemas/remnawave.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class RemnawaveBaseModel(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
||||
|
||||
|
||||
class ActiveInternalSquad(RemnawaveBaseModel):
|
||||
uuid: UUID
|
||||
name: str
|
||||
|
||||
|
||||
class UserTraffic(RemnawaveBaseModel):
|
||||
used_traffic_bytes: float = Field(alias="usedTrafficBytes")
|
||||
lifetime_used_traffic_bytes: float = Field(alias="lifetimeUsedTrafficBytes")
|
||||
online_at: datetime | None = Field(default=None, alias="onlineAt")
|
||||
first_connected_at: datetime | None = Field(default=None, alias="firstConnectedAt")
|
||||
last_connected_node_uuid: UUID | None = Field(default=None, alias="lastConnectedNodeUuid")
|
||||
|
||||
|
||||
class RemnawaveUser(RemnawaveBaseModel):
|
||||
uuid: UUID
|
||||
id: int
|
||||
short_uuid: str = Field(alias="shortUuid")
|
||||
username: str
|
||||
status: str
|
||||
traffic_limit_bytes: int = Field(alias="trafficLimitBytes")
|
||||
expire_at: datetime = Field(alias="expireAt")
|
||||
telegram_id: int | None = Field(default=None, alias="telegramId")
|
||||
email: str | None = None
|
||||
description: str | None = None
|
||||
tag: str | None = None
|
||||
hwid_device_limit: int | None = Field(default=None, alias="hwidDeviceLimit")
|
||||
external_squad_uuid: UUID | None = Field(default=None, alias="externalSquadUuid")
|
||||
trojan_password: str = Field(alias="trojanPassword")
|
||||
vless_uuid: UUID = Field(alias="vlessUuid")
|
||||
ss_password: str = Field(alias="ssPassword")
|
||||
last_triggered_threshold: int = Field(alias="lastTriggeredThreshold")
|
||||
sub_revoked_at: datetime | None = Field(default=None, alias="subRevokedAt")
|
||||
sub_last_user_agent: str | None = Field(default=None, alias="subLastUserAgent")
|
||||
sub_last_opened_at: datetime | None = Field(default=None, alias="subLastOpenedAt")
|
||||
last_traffic_reset_at: datetime | None = Field(default=None, alias="lastTrafficResetAt")
|
||||
created_at: datetime = Field(alias="createdAt")
|
||||
updated_at: datetime = Field(alias="updatedAt")
|
||||
subscription_url: str = Field(alias="subscriptionUrl")
|
||||
active_internal_squads: list[ActiveInternalSquad] = Field(alias="activeInternalSquads")
|
||||
user_traffic: UserTraffic = Field(alias="userTraffic")
|
||||
|
||||
|
||||
class PaginatedUsers(RemnawaveBaseModel):
|
||||
users: list[RemnawaveUser]
|
||||
total: int
|
||||
|
||||
|
||||
class ResolvedUser(RemnawaveBaseModel):
|
||||
uuid: UUID
|
||||
username: str
|
||||
id: int
|
||||
short_uuid: str = Field(alias="shortUuid")
|
||||
|
||||
|
||||
class SubscriptionRequestRecord(RemnawaveBaseModel):
|
||||
id: int
|
||||
user_uuid: UUID = Field(alias="userUuid")
|
||||
request_at: datetime = Field(alias="requestAt")
|
||||
request_ip: str | None = Field(default=None, alias="requestIp")
|
||||
user_agent: str | None = Field(default=None, alias="userAgent")
|
||||
|
||||
|
||||
class SubscriptionRequestHistory(RemnawaveBaseModel):
|
||||
total: int
|
||||
records: list[SubscriptionRequestRecord]
|
||||
1
app/services/__init__.py
Normal file
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Services package."""
|
||||
744
app/services/bot_config_service.py
Normal file
744
app/services/bot_config_service.py
Normal file
@@ -0,0 +1,744 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.config import PaymentPlanSettings, Settings
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import BotSetting
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BotConfigFieldSpec:
|
||||
key: str
|
||||
label: str
|
||||
section: str
|
||||
prompt: str
|
||||
description: str
|
||||
placeholder: str = ""
|
||||
is_secret_like: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BotConfigSnapshot:
|
||||
bot_brand_name: str
|
||||
bot_public_username: str
|
||||
bot_support_url: str
|
||||
bot_terms_url: str
|
||||
bot_support_ticket_link: str
|
||||
payment_review_link: str
|
||||
bot_start_image_enabled: bool
|
||||
bot_start_image_path: str
|
||||
referral_enabled: bool
|
||||
referral_discount_percent: int
|
||||
referral_bonus_days: int
|
||||
payment_plans_raw: str
|
||||
payment_transfer_text: str
|
||||
payment_support_text: str
|
||||
payment_plan_traffic_limit_gb: int
|
||||
payment_plan_traffic_reset_period: str
|
||||
payment_internal_squad_uuids_raw: str
|
||||
payment_external_squad_uuid: str
|
||||
payment_username_prefix: str
|
||||
payment_user_tag: str
|
||||
notification_enabled: bool = True
|
||||
notification_days_before: str = "1,3"
|
||||
notification_check_interval_hours: int = 6
|
||||
fallback_support_ticket_chat_id: int | None = None
|
||||
fallback_support_ticket_thread_id: int | None = None
|
||||
fallback_payment_review_chat_id: int | None = None
|
||||
fallback_payment_review_thread_id: int | None = None
|
||||
|
||||
@property
|
||||
def bot_public_username_normalized(self) -> str:
|
||||
return self.bot_public_username.strip().removeprefix("@")
|
||||
|
||||
@property
|
||||
def payment_external_squad_uuid_normalized(self) -> str | None:
|
||||
value = self.payment_external_squad_uuid.strip()
|
||||
return value or None
|
||||
|
||||
@property
|
||||
def payment_username_prefix_normalized(self) -> str:
|
||||
normalized = "".join(
|
||||
ch for ch in self.payment_username_prefix.strip() if ch.isalnum() or ch in "_-"
|
||||
)
|
||||
return normalized[:12] or "Oreol"
|
||||
|
||||
@property
|
||||
def payment_user_tag_normalized(self) -> str | None:
|
||||
normalized = "".join(
|
||||
ch for ch in self.payment_user_tag.strip().upper() if ch.isalnum() or ch == "_"
|
||||
)
|
||||
return normalized[:16] or None
|
||||
|
||||
@property
|
||||
def payment_internal_squad_uuids(self) -> list[str]:
|
||||
values: list[str] = []
|
||||
for raw_part in self.payment_internal_squad_uuids_raw.split(","):
|
||||
part = raw_part.strip()
|
||||
if part:
|
||||
values.append(part)
|
||||
return values
|
||||
|
||||
@property
|
||||
def notification_days_list(self) -> list[int]:
|
||||
values: list[int] = []
|
||||
for raw_part in self.notification_days_before.split(","):
|
||||
part = raw_part.strip()
|
||||
if part.isdigit():
|
||||
day = int(part)
|
||||
if day > 0:
|
||||
values.append(day)
|
||||
return sorted(set(values), reverse=True) if values else [1, 3]
|
||||
|
||||
@property
|
||||
def payment_plans(self) -> list[PaymentPlanSettings]:
|
||||
values: list[PaymentPlanSettings] = []
|
||||
for raw_part in self.payment_plans_raw.split(","):
|
||||
part = raw_part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
fragments = [fragment.strip() for fragment in part.split(":")]
|
||||
if len(fragments) != 2 or not fragments[0].isdigit() or not fragments[1].isdigit():
|
||||
continue
|
||||
|
||||
days = int(fragments[0])
|
||||
amount_rub = int(fragments[1])
|
||||
if days <= 0 or amount_rub <= 0:
|
||||
continue
|
||||
|
||||
values.append(
|
||||
PaymentPlanSettings(
|
||||
code=f"{days}d",
|
||||
days=days,
|
||||
amount_rub=amount_rub,
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(values, key=lambda item: item.days)
|
||||
|
||||
@staticmethod
|
||||
def _compact_text(raw_value: str, *, max_length: int = 80) -> str:
|
||||
normalized = " ".join(raw_value.split()).strip()
|
||||
if not normalized:
|
||||
return "-"
|
||||
if len(normalized) <= max_length:
|
||||
return normalized
|
||||
return f"{normalized[: max_length - 3]}..."
|
||||
|
||||
@property
|
||||
def support_ticket_chat_id(self) -> int | None:
|
||||
parsed_chat_id, _ = Settings._parse_private_topic_link(self.bot_support_ticket_link)
|
||||
return parsed_chat_id if parsed_chat_id is not None else self.fallback_support_ticket_chat_id
|
||||
|
||||
@property
|
||||
def support_ticket_message_thread_id(self) -> int | None:
|
||||
_, parsed_thread_id = Settings._parse_private_topic_link(self.bot_support_ticket_link)
|
||||
return (
|
||||
parsed_thread_id
|
||||
if parsed_thread_id is not None
|
||||
else self.fallback_support_ticket_thread_id
|
||||
)
|
||||
|
||||
@property
|
||||
def payment_review_chat_id(self) -> int | None:
|
||||
parsed_chat_id, _ = Settings._parse_private_topic_link(self.payment_review_link)
|
||||
return parsed_chat_id if parsed_chat_id is not None else self.fallback_payment_review_chat_id
|
||||
|
||||
@property
|
||||
def payment_review_message_thread_id(self) -> int | None:
|
||||
_, parsed_thread_id = Settings._parse_private_topic_link(self.payment_review_link)
|
||||
return (
|
||||
parsed_thread_id
|
||||
if parsed_thread_id is not None
|
||||
else self.fallback_payment_review_thread_id
|
||||
)
|
||||
|
||||
|
||||
class StaticBotConfigService:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
|
||||
async def get_snapshot(self) -> BotConfigSnapshot:
|
||||
return BotConfigSnapshot(
|
||||
bot_brand_name=self._settings.bot_brand_name,
|
||||
bot_public_username=self._settings.bot_public_username,
|
||||
bot_support_url=self._settings.bot_support_url,
|
||||
bot_terms_url=self._settings.bot_terms_url,
|
||||
bot_support_ticket_link=self._settings.bot_support_ticket_link,
|
||||
payment_review_link=self._settings.payment_review_link,
|
||||
bot_start_image_enabled=self._settings.bot_start_image_enabled,
|
||||
bot_start_image_path=self._settings.bot_start_image_path,
|
||||
referral_enabled=True,
|
||||
referral_discount_percent=self._settings.referral_discount_percent,
|
||||
referral_bonus_days=self._settings.referral_bonus_days,
|
||||
payment_plans_raw=self._settings.payment_plans_raw,
|
||||
payment_transfer_text=self._settings.payment_transfer_text,
|
||||
payment_support_text=self._settings.payment_support_text,
|
||||
payment_plan_traffic_limit_gb=self._settings.payment_plan_traffic_limit_gb,
|
||||
payment_plan_traffic_reset_period=self._settings.payment_plan_traffic_reset_period,
|
||||
notification_enabled=True,
|
||||
notification_days_before="1,3",
|
||||
notification_check_interval_hours=6,
|
||||
payment_internal_squad_uuids_raw=self._settings.payment_internal_squad_uuids_raw,
|
||||
payment_external_squad_uuid=self._settings.payment_external_squad_uuid,
|
||||
payment_username_prefix=self._settings.payment_username_prefix,
|
||||
payment_user_tag=self._settings.payment_user_tag,
|
||||
fallback_support_ticket_chat_id=self._settings.support_ticket_chat_id,
|
||||
fallback_support_ticket_thread_id=self._settings.support_ticket_message_thread_id,
|
||||
fallback_payment_review_chat_id=self._settings.payment_review_chat_id,
|
||||
fallback_payment_review_thread_id=self._settings.payment_review_message_thread_id,
|
||||
)
|
||||
|
||||
|
||||
class BotConfigService(StaticBotConfigService):
|
||||
FIELD_SPECS: dict[str, BotConfigFieldSpec] = {
|
||||
"bot_brand_name": BotConfigFieldSpec(
|
||||
key="bot_brand_name",
|
||||
label="Название бренда",
|
||||
section="brand",
|
||||
prompt="Введите новое название бренда.",
|
||||
description="Используется в главной панели и пользовательских сообщениях.",
|
||||
placeholder="OREOL VPN",
|
||||
),
|
||||
"bot_public_username": BotConfigFieldSpec(
|
||||
key="bot_public_username",
|
||||
label="Username бота",
|
||||
section="brand",
|
||||
prompt="Введите username бота без @. Для очистки отправьте `-`.",
|
||||
description="Нужен для генерации реферальных ссылок.",
|
||||
placeholder="oreol_vpn_bot",
|
||||
),
|
||||
"bot_start_image_enabled": BotConfigFieldSpec(
|
||||
key="bot_start_image_enabled",
|
||||
label="Стартовая картинка",
|
||||
section="brand",
|
||||
prompt="Введите `on` или `off`.",
|
||||
description="Включает или выключает бренд-картинку в панели и выдаче доступа.",
|
||||
placeholder="on",
|
||||
),
|
||||
"bot_start_image_path": BotConfigFieldSpec(
|
||||
key="bot_start_image_path",
|
||||
label="Путь к картинке",
|
||||
section="brand",
|
||||
prompt="Введите путь к изображению. Для очистки отправьте `-`.",
|
||||
description="Можно указать относительный путь от корня проекта.",
|
||||
placeholder="assets/main.png",
|
||||
),
|
||||
"payment_plans_raw": BotConfigFieldSpec(
|
||||
key="payment_plans_raw",
|
||||
label="Тарифы и цены",
|
||||
section="pricing",
|
||||
prompt="Введите тарифы в формате `30:250,180:600,365:1000`.",
|
||||
description="Каждая пара — это `дни:цена_в_рублях`.",
|
||||
placeholder="30:250,180:600,365:1000",
|
||||
),
|
||||
"payment_plan_traffic_limit_gb": BotConfigFieldSpec(
|
||||
key="payment_plan_traffic_limit_gb",
|
||||
label="Лимит трафика, GB",
|
||||
section="pricing",
|
||||
prompt="Введите лимит трафика в гигабайтах. `0` = безлимит.",
|
||||
description="Применяется к тарифам, создаваемым ботом.",
|
||||
placeholder="0",
|
||||
),
|
||||
"payment_plan_traffic_reset_period": BotConfigFieldSpec(
|
||||
key="payment_plan_traffic_reset_period",
|
||||
label="Стратегия сброса трафика",
|
||||
section="pricing",
|
||||
prompt="Введите стратегию, например `NO_RESET`.",
|
||||
description="Значение передаётся в Remnawave.",
|
||||
placeholder="NO_RESET",
|
||||
),
|
||||
"referral_enabled": BotConfigFieldSpec(
|
||||
key="referral_enabled",
|
||||
label="Реферальная система",
|
||||
section="referral",
|
||||
prompt="Введите `on` или `off`.",
|
||||
description="Включает или выключает ввод рефкода и начисление реферальных бонусов.",
|
||||
placeholder="on",
|
||||
),
|
||||
"referral_discount_percent": BotConfigFieldSpec(
|
||||
key="referral_discount_percent",
|
||||
label="Скидка по рефкоду, %",
|
||||
section="referral",
|
||||
prompt="Введите размер скидки в процентах от 0 до 100.",
|
||||
description="Применяется к покупателю перед оплатой.",
|
||||
placeholder="5",
|
||||
),
|
||||
"referral_bonus_days": BotConfigFieldSpec(
|
||||
key="referral_bonus_days",
|
||||
label="Бонус рефереру, дней",
|
||||
section="referral",
|
||||
prompt="Введите количество бонусных дней. `0` отключает бонус.",
|
||||
description="Начисляется после подтверждённой оплаты по рефкоду.",
|
||||
placeholder="7",
|
||||
),
|
||||
"bot_support_url": BotConfigFieldSpec(
|
||||
key="bot_support_url",
|
||||
label="Ссылка на поддержку",
|
||||
section="links",
|
||||
prompt="Введите URL поддержки. Для очистки отправьте `-`.",
|
||||
description="Кнопка быстрой связи с поддержкой в пользовательской панели.",
|
||||
placeholder="https://t.me/your_support_account",
|
||||
),
|
||||
"bot_terms_url": BotConfigFieldSpec(
|
||||
key="bot_terms_url",
|
||||
label="Ссылка на Terms",
|
||||
section="links",
|
||||
prompt="Введите URL правил использования. Для очистки отправьте `-`.",
|
||||
description="Открывается из раздела условий использования.",
|
||||
placeholder="https://example.com/terms",
|
||||
),
|
||||
"bot_support_ticket_link": BotConfigFieldSpec(
|
||||
key="bot_support_ticket_link",
|
||||
label="Ссылка на чат тикетов",
|
||||
section="links",
|
||||
prompt="Введите приватную ссылку на чат/топик тикетов. Для очистки отправьте `-`.",
|
||||
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
|
||||
placeholder="https://t.me/c/1234567890/2/3",
|
||||
),
|
||||
"payment_review_link": BotConfigFieldSpec(
|
||||
key="payment_review_link",
|
||||
label="Ссылка на очередь оплат",
|
||||
section="links",
|
||||
prompt="Введите приватную ссылку на чат/топик проверки оплат. Для очистки отправьте `-`.",
|
||||
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
|
||||
placeholder="https://t.me/c/1234567890/56/57",
|
||||
),
|
||||
"payment_transfer_text": BotConfigFieldSpec(
|
||||
key="payment_transfer_text",
|
||||
label="Инструкция по оплате",
|
||||
section="texts",
|
||||
prompt="Введите текст с реквизитами или инструкцией перевода.",
|
||||
description="Показывается пользователю после выбора тарифа.",
|
||||
placeholder="Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN",
|
||||
),
|
||||
"payment_support_text": BotConfigFieldSpec(
|
||||
key="payment_support_text",
|
||||
label="Текст поддержки по оплате",
|
||||
section="texts",
|
||||
prompt="Введите текст, который бот покажет при проблемах с оплатой.",
|
||||
description="Используется при отклонении оплаты и ошибках выдачи доступа.",
|
||||
placeholder="Если оплата прошла, но доступ не выдался, напишите в поддержку.",
|
||||
),
|
||||
"payment_internal_squad_uuids_raw": BotConfigFieldSpec(
|
||||
key="payment_internal_squad_uuids_raw",
|
||||
label="UUID внутренних групп",
|
||||
section="provisioning",
|
||||
prompt="Введите UUID внутренних групп через запятую.",
|
||||
description="Эти группы назначаются пользователю в Remnawave.",
|
||||
placeholder="11111111-1111-1111-1111-111111111111",
|
||||
),
|
||||
"payment_external_squad_uuid": BotConfigFieldSpec(
|
||||
key="payment_external_squad_uuid",
|
||||
label="UUID внешней группы",
|
||||
section="provisioning",
|
||||
prompt="Введите UUID внешней группы. Для очистки отправьте `-`.",
|
||||
description="Необязательный внешний squad для новых пользователей.",
|
||||
placeholder="11111111-1111-1111-1111-111111111111",
|
||||
),
|
||||
"payment_username_prefix": BotConfigFieldSpec(
|
||||
key="payment_username_prefix",
|
||||
label="Префикс логина",
|
||||
section="provisioning",
|
||||
prompt="Введите префикс логина для новых пользователей Remnawave.",
|
||||
description="Итоговый логин строится как `<prefix>-<telegram_id>-<name>`.",
|
||||
placeholder="Oreol",
|
||||
),
|
||||
"payment_user_tag": BotConfigFieldSpec(
|
||||
key="payment_user_tag",
|
||||
label="Tag пользователей",
|
||||
section="provisioning",
|
||||
prompt="Введите tag для создаваемых пользователей. Для очистки отправьте `-`.",
|
||||
description="Опциональный tag в Remnawave.",
|
||||
placeholder="BOT",
|
||||
),
|
||||
"notification_enabled": BotConfigFieldSpec(
|
||||
key="notification_enabled",
|
||||
label="Уведомления об истечении",
|
||||
section="notifications",
|
||||
prompt="Введите `on` или `off`.",
|
||||
description="Включает или выключает уведомления о скором истечении подписки.",
|
||||
placeholder="on",
|
||||
),
|
||||
"notification_days_before": BotConfigFieldSpec(
|
||||
key="notification_days_before",
|
||||
label="Дни до истечения",
|
||||
section="notifications",
|
||||
prompt="Введите дни через запятую, например `1,3`.",
|
||||
description="За сколько дней до истечения отправлять уведомления.",
|
||||
placeholder="1,3",
|
||||
),
|
||||
"notification_check_interval_hours": BotConfigFieldSpec(
|
||||
key="notification_check_interval_hours",
|
||||
label="Интервал проверки, ч",
|
||||
section="notifications",
|
||||
prompt="Введите интервал проверки в часах.",
|
||||
description="Как часто бот проверяет подписки на истечение.",
|
||||
placeholder="6",
|
||||
),
|
||||
}
|
||||
|
||||
SECTION_LABELS: dict[str, str] = {
|
||||
"brand": "Бренд",
|
||||
"pricing": "Тарифы",
|
||||
"referral": "Рефералка",
|
||||
"links": "Ссылки",
|
||||
"texts": "Тексты",
|
||||
"provisioning": "Provisioning",
|
||||
"notifications": "Уведомления",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Settings,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
super().__init__(settings)
|
||||
self._session_factory = session_factory
|
||||
|
||||
async def get_snapshot(self) -> BotConfigSnapshot:
|
||||
overrides = await self._get_overrides()
|
||||
|
||||
return BotConfigSnapshot(
|
||||
bot_brand_name=str(overrides.get("bot_brand_name", self._settings.bot_brand_name)),
|
||||
bot_public_username=str(overrides.get("bot_public_username", self._settings.bot_public_username)),
|
||||
bot_support_url=str(overrides.get("bot_support_url", self._settings.bot_support_url)),
|
||||
bot_terms_url=str(overrides.get("bot_terms_url", self._settings.bot_terms_url)),
|
||||
bot_support_ticket_link=str(overrides.get("bot_support_ticket_link", self._settings.bot_support_ticket_link)),
|
||||
payment_review_link=str(overrides.get("payment_review_link", self._settings.payment_review_link)),
|
||||
bot_start_image_enabled=self._parse_bool(
|
||||
overrides.get("bot_start_image_enabled"),
|
||||
default=self._settings.bot_start_image_enabled,
|
||||
),
|
||||
bot_start_image_path=str(overrides.get("bot_start_image_path", self._settings.bot_start_image_path)),
|
||||
referral_enabled=self._parse_bool(
|
||||
overrides.get("referral_enabled"),
|
||||
default=True,
|
||||
),
|
||||
referral_discount_percent=self._parse_int(
|
||||
overrides.get("referral_discount_percent"),
|
||||
default=self._settings.referral_discount_percent,
|
||||
),
|
||||
referral_bonus_days=self._parse_int(
|
||||
overrides.get("referral_bonus_days"),
|
||||
default=self._settings.referral_bonus_days,
|
||||
),
|
||||
payment_plans_raw=str(overrides.get("payment_plans_raw", self._settings.payment_plans_raw)),
|
||||
payment_transfer_text=str(overrides.get("payment_transfer_text", self._settings.payment_transfer_text)),
|
||||
payment_support_text=str(overrides.get("payment_support_text", self._settings.payment_support_text)),
|
||||
payment_plan_traffic_limit_gb=self._parse_int(
|
||||
overrides.get("payment_plan_traffic_limit_gb"),
|
||||
default=self._settings.payment_plan_traffic_limit_gb,
|
||||
),
|
||||
payment_plan_traffic_reset_period=str(
|
||||
overrides.get(
|
||||
"payment_plan_traffic_reset_period",
|
||||
self._settings.payment_plan_traffic_reset_period,
|
||||
)
|
||||
),
|
||||
payment_internal_squad_uuids_raw=str(
|
||||
overrides.get(
|
||||
"payment_internal_squad_uuids_raw",
|
||||
self._settings.payment_internal_squad_uuids_raw,
|
||||
)
|
||||
),
|
||||
payment_external_squad_uuid=str(
|
||||
overrides.get(
|
||||
"payment_external_squad_uuid",
|
||||
self._settings.payment_external_squad_uuid,
|
||||
)
|
||||
),
|
||||
payment_username_prefix=str(
|
||||
overrides.get("payment_username_prefix", self._settings.payment_username_prefix)
|
||||
),
|
||||
payment_user_tag=str(overrides.get("payment_user_tag", self._settings.payment_user_tag)),
|
||||
notification_enabled=self._parse_bool(
|
||||
overrides.get("notification_enabled"),
|
||||
default=True,
|
||||
),
|
||||
notification_days_before=str(overrides.get("notification_days_before", "1,3")),
|
||||
notification_check_interval_hours=self._parse_int(
|
||||
overrides.get("notification_check_interval_hours"),
|
||||
default=6,
|
||||
),
|
||||
fallback_support_ticket_chat_id=self._settings.support_ticket_chat_id,
|
||||
fallback_support_ticket_thread_id=self._settings.support_ticket_message_thread_id,
|
||||
fallback_payment_review_chat_id=self._settings.payment_review_chat_id,
|
||||
fallback_payment_review_thread_id=self._settings.payment_review_message_thread_id,
|
||||
)
|
||||
|
||||
async def update_setting(
|
||||
self,
|
||||
*,
|
||||
key: str,
|
||||
raw_value: str,
|
||||
updated_by_telegram_id: int,
|
||||
) -> str:
|
||||
spec = self.FIELD_SPECS.get(key)
|
||||
if spec is None:
|
||||
raise ValueError("Неизвестная настройка.")
|
||||
|
||||
normalized_value = self._normalize_value(key, raw_value)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
record = await session.scalar(
|
||||
select(BotSetting).where(BotSetting.key == key)
|
||||
)
|
||||
if record is None:
|
||||
record = BotSetting(key=key)
|
||||
session.add(record)
|
||||
|
||||
record.value = normalized_value
|
||||
record.updated_by_telegram_id = updated_by_telegram_id
|
||||
record.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
return normalized_value
|
||||
|
||||
@classmethod
|
||||
def get_spec(cls, key: str) -> BotConfigFieldSpec | None:
|
||||
return cls.FIELD_SPECS.get(key)
|
||||
|
||||
@classmethod
|
||||
def get_specs_for_section(cls, section: str) -> list[BotConfigFieldSpec]:
|
||||
return [spec for spec in cls.FIELD_SPECS.values() if spec.section == section]
|
||||
|
||||
@classmethod
|
||||
def get_sections(cls) -> list[tuple[str, str]]:
|
||||
return list(cls.SECTION_LABELS.items())
|
||||
|
||||
@classmethod
|
||||
def get_section_label(cls, section: str) -> str:
|
||||
return cls.SECTION_LABELS.get(section, section)
|
||||
|
||||
@classmethod
|
||||
def format_value(cls, *, snapshot: BotConfigSnapshot, key: str) -> str:
|
||||
value = getattr(snapshot, key, "")
|
||||
|
||||
if key in {"bot_start_image_enabled", "referral_enabled", "notification_enabled"}:
|
||||
return "on" if bool(value) else "off"
|
||||
|
||||
if key == "bot_public_username":
|
||||
normalized = snapshot.bot_public_username_normalized
|
||||
return f"@{normalized}" if normalized else "-"
|
||||
|
||||
if key in {
|
||||
"bot_support_url",
|
||||
"bot_terms_url",
|
||||
"bot_support_ticket_link",
|
||||
"payment_review_link",
|
||||
"bot_start_image_path",
|
||||
"payment_external_squad_uuid",
|
||||
"payment_user_tag",
|
||||
"payment_username_prefix",
|
||||
"payment_internal_squad_uuids_raw",
|
||||
"payment_plans_raw",
|
||||
"payment_plan_traffic_reset_period",
|
||||
"bot_brand_name",
|
||||
"notification_days_before",
|
||||
}:
|
||||
return cls._compact_text(str(value))
|
||||
|
||||
if key == "payment_plan_traffic_limit_gb":
|
||||
amount = int(value or 0)
|
||||
return "0 GB (unlimited)" if amount <= 0 else f"{amount} GB"
|
||||
|
||||
if key == "referral_discount_percent":
|
||||
return f"{max(int(value or 0), 0)}%"
|
||||
|
||||
if key == "referral_bonus_days":
|
||||
return f"{max(int(value or 0), 0)} days"
|
||||
|
||||
if key == "notification_check_interval_hours":
|
||||
return f"{max(int(value or 6), 1)} ч"
|
||||
|
||||
if key in {"payment_transfer_text", "payment_support_text"}:
|
||||
return cls._compact_text(str(value), max_length=120)
|
||||
|
||||
return cls._compact_text(str(value))
|
||||
|
||||
@staticmethod
|
||||
def _compact_text(raw_value: str, *, max_length: int = 80) -> str:
|
||||
normalized = " ".join(raw_value.split()).strip()
|
||||
if not normalized:
|
||||
return "-"
|
||||
if len(normalized) <= max_length:
|
||||
return normalized
|
||||
return f"{normalized[: max_length - 3]}..."
|
||||
|
||||
async def _get_overrides(self) -> dict[str, str]:
|
||||
async with self._session_factory() as session:
|
||||
rows = await session.execute(select(BotSetting))
|
||||
return {
|
||||
row.key: row.value
|
||||
for row in rows.scalars().all()
|
||||
}
|
||||
|
||||
def _normalize_value(self, key: str, raw_value: str) -> str:
|
||||
value = raw_value.strip()
|
||||
if key in {
|
||||
"bot_support_url",
|
||||
"bot_terms_url",
|
||||
"bot_support_ticket_link",
|
||||
"payment_review_link",
|
||||
"bot_start_image_path",
|
||||
"payment_external_squad_uuid",
|
||||
"payment_user_tag",
|
||||
"bot_public_username",
|
||||
} and value == "-":
|
||||
return ""
|
||||
|
||||
if key == "bot_brand_name":
|
||||
if not value:
|
||||
raise ValueError("Название бренда не может быть пустым.")
|
||||
return value[:80]
|
||||
|
||||
if key == "bot_public_username":
|
||||
return value.removeprefix("@")
|
||||
|
||||
if key == "bot_start_image_enabled":
|
||||
return "true" if self._parse_bool_token(value) else "false"
|
||||
|
||||
if key == "bot_start_image_path":
|
||||
return value
|
||||
|
||||
if key == "payment_plans_raw":
|
||||
plans = self._parse_payment_plans(value)
|
||||
return ",".join(f"{plan.days}:{plan.amount_rub}" for plan in plans)
|
||||
|
||||
if key == "payment_plan_traffic_limit_gb":
|
||||
amount = self._parse_non_negative_int(value, "Лимит трафика")
|
||||
return str(amount)
|
||||
|
||||
if key == "payment_plan_traffic_reset_period":
|
||||
if not value:
|
||||
raise ValueError("Стратегия сброса трафика не может быть пустой.")
|
||||
return value.upper()
|
||||
|
||||
if key == "referral_enabled":
|
||||
return "true" if self._parse_bool_token(value) else "false"
|
||||
|
||||
if key == "referral_discount_percent":
|
||||
amount = self._parse_non_negative_int(value, "Размер скидки")
|
||||
if amount > 100:
|
||||
raise ValueError("Размер скидки должен быть в диапазоне от 0 до 100.")
|
||||
return str(amount)
|
||||
|
||||
if key == "referral_bonus_days":
|
||||
amount = self._parse_non_negative_int(value, "Размер бонуса")
|
||||
return str(amount)
|
||||
|
||||
if key in {
|
||||
"bot_support_url",
|
||||
"bot_terms_url",
|
||||
"bot_support_ticket_link",
|
||||
"payment_review_link",
|
||||
}:
|
||||
return value
|
||||
|
||||
if key in {"payment_transfer_text", "payment_support_text"}:
|
||||
if not value:
|
||||
raise ValueError("Текст не может быть пустым.")
|
||||
return value[:4000]
|
||||
|
||||
if key == "payment_internal_squad_uuids_raw":
|
||||
normalized_parts = [part.strip() for part in value.split(",") if part.strip()]
|
||||
if not normalized_parts:
|
||||
raise ValueError("Нужно указать хотя бы один UUID внутренней группы.")
|
||||
return ",".join(normalized_parts)
|
||||
|
||||
if key == "payment_external_squad_uuid":
|
||||
return value
|
||||
|
||||
if key == "payment_username_prefix":
|
||||
if not value:
|
||||
raise ValueError("Префикс логина не может быть пустым.")
|
||||
return value[:32]
|
||||
|
||||
if key == "payment_user_tag":
|
||||
return value
|
||||
|
||||
if key == "notification_enabled":
|
||||
return "true" if self._parse_bool_token(value) else "false"
|
||||
|
||||
if key == "notification_days_before":
|
||||
parts = [p.strip() for p in value.split(",") if p.strip().isdigit() and int(p.strip()) > 0]
|
||||
if not parts:
|
||||
raise ValueError("Нужно указать хотя бы один день, например `1,3`.")
|
||||
return ",".join(parts)
|
||||
|
||||
if key == "notification_check_interval_hours":
|
||||
amount = self._parse_non_negative_int(value, "Интервал проверки")
|
||||
if amount < 1:
|
||||
raise ValueError("Интервал должен быть не менее 1 часа.")
|
||||
return str(amount)
|
||||
|
||||
raise ValueError("Настройка пока не поддерживается.")
|
||||
|
||||
@staticmethod
|
||||
def _parse_int(raw_value: object | None, *, default: int) -> int:
|
||||
try:
|
||||
return int(str(raw_value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _parse_bool(raw_value: object | None, *, default: bool) -> bool:
|
||||
if raw_value is None:
|
||||
return default
|
||||
try:
|
||||
return BotConfigService._parse_bool_token(str(raw_value))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _parse_bool_token(raw_value: str) -> bool:
|
||||
normalized = raw_value.strip().lower()
|
||||
if normalized in {"1", "true", "on", "yes", "y", "да", "вкл"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "off", "no", "n", "нет", "выкл"}:
|
||||
return False
|
||||
raise ValueError("Введите `on` или `off`.")
|
||||
|
||||
@staticmethod
|
||||
def _parse_non_negative_int(raw_value: str, label: str) -> int:
|
||||
if not raw_value.strip().isdigit():
|
||||
raise ValueError(f"{label} должен быть целым неотрицательным числом.")
|
||||
return int(raw_value.strip())
|
||||
|
||||
@staticmethod
|
||||
def _parse_payment_plans(raw_value: str) -> list[PaymentPlanSettings]:
|
||||
values: list[PaymentPlanSettings] = []
|
||||
for raw_part in raw_value.split(","):
|
||||
part = raw_part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
fragments = [fragment.strip() for fragment in part.split(":")]
|
||||
if len(fragments) != 2 or not fragments[0].isdigit() or not fragments[1].isdigit():
|
||||
raise ValueError(
|
||||
"Тарифы должны быть в формате `дни:цена`, например `30:250,180:600`."
|
||||
)
|
||||
|
||||
days = int(fragments[0])
|
||||
amount_rub = int(fragments[1])
|
||||
if days <= 0 or amount_rub <= 0:
|
||||
raise ValueError("У тарифов дни и цена должны быть больше нуля.")
|
||||
|
||||
values.append(
|
||||
PaymentPlanSettings(
|
||||
code=f"{days}d",
|
||||
days=days,
|
||||
amount_rub=amount_rub,
|
||||
)
|
||||
)
|
||||
|
||||
if not values:
|
||||
raise ValueError("Нужно указать хотя бы один тариф.")
|
||||
|
||||
return sorted(values, key=lambda item: item.days)
|
||||
239
app/services/notification_service.py
Normal file
239
app/services/notification_service.py
Normal file
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import SubscriptionNotification
|
||||
from app.services.bot_config_service import BotConfigSnapshot, StaticBotConfigService
|
||||
from app.services.sync_service import SyncService
|
||||
from app.utils.formatters import format_datetime_with_days_left
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NOTIFICATION_TYPE_EXPIRE_SOON = "EXPIRE_SOON"
|
||||
|
||||
|
||||
class SubscriptionNotificationService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
sync_service: SyncService,
|
||||
config_service: StaticBotConfigService,
|
||||
project_root: Path,
|
||||
) -> None:
|
||||
self._session_factory = session_factory
|
||||
self._sync_service = sync_service
|
||||
self._config_service = config_service
|
||||
self._project_root = project_root
|
||||
|
||||
async def _get_config(self) -> BotConfigSnapshot:
|
||||
return await self._config_service.get_snapshot()
|
||||
|
||||
def _resolve_image_path(self, config: BotConfigSnapshot) -> Path | None:
|
||||
if not config.bot_start_image_enabled:
|
||||
return None
|
||||
|
||||
raw_path = config.bot_start_image_path.strip()
|
||||
default_path = self._project_root / "assets" / "main.png"
|
||||
|
||||
if not raw_path:
|
||||
return default_path if default_path.is_file() else None
|
||||
|
||||
image_path = Path(raw_path).expanduser()
|
||||
if not image_path.is_absolute():
|
||||
image_path = self._project_root / image_path
|
||||
|
||||
return image_path if image_path.is_file() else None
|
||||
|
||||
async def _already_notified(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
remnawave_user_id: int,
|
||||
notification_type: str,
|
||||
expire_at_snapshot: datetime,
|
||||
) -> bool:
|
||||
existing = await session.scalar(
|
||||
select(SubscriptionNotification.id).where(
|
||||
SubscriptionNotification.remnawave_user_id == remnawave_user_id,
|
||||
SubscriptionNotification.notification_type == notification_type,
|
||||
SubscriptionNotification.expire_at_snapshot == expire_at_snapshot,
|
||||
)
|
||||
)
|
||||
return existing is not None
|
||||
|
||||
async def _record_notification(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
remnawave_user_id: int,
|
||||
telegram_id: int,
|
||||
notification_type: str,
|
||||
expire_at_snapshot: datetime,
|
||||
) -> None:
|
||||
session.add(
|
||||
SubscriptionNotification(
|
||||
remnawave_user_id=remnawave_user_id,
|
||||
telegram_id=telegram_id,
|
||||
notification_type=notification_type,
|
||||
expire_at_snapshot=expire_at_snapshot,
|
||||
sent_at=utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
async def check_and_notify(self, bot: Bot) -> int:
|
||||
config = await self._get_config()
|
||||
if not config.notification_enabled:
|
||||
return 0
|
||||
|
||||
days_list = config.notification_days_list
|
||||
if not days_list:
|
||||
return 0
|
||||
|
||||
total_sent = 0
|
||||
image_path = self._resolve_image_path(config)
|
||||
|
||||
for days_before in days_list:
|
||||
expiring = await self._sync_service.get_expiring_users(
|
||||
days_before=days_before,
|
||||
tolerance_hours=max(config.notification_check_interval_hours, 1),
|
||||
)
|
||||
|
||||
for rw_user_id, telegram_id, expire_at in expiring:
|
||||
if telegram_id is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
sent = await self._send_notification(
|
||||
bot,
|
||||
rw_user_id=rw_user_id,
|
||||
telegram_id=telegram_id,
|
||||
expire_at=expire_at,
|
||||
days_before=days_before,
|
||||
brand_name=config.bot_brand_name,
|
||||
image_path=image_path,
|
||||
)
|
||||
if sent:
|
||||
total_sent += 1
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to send expiration notification to telegram_id=%s",
|
||||
telegram_id,
|
||||
)
|
||||
|
||||
return total_sent
|
||||
|
||||
async def _send_notification(
|
||||
self,
|
||||
bot: Bot,
|
||||
*,
|
||||
rw_user_id: int,
|
||||
telegram_id: int,
|
||||
expire_at: datetime,
|
||||
days_before: int,
|
||||
brand_name: str,
|
||||
image_path: Path | None,
|
||||
) -> bool:
|
||||
notification_type = f"{NOTIFICATION_TYPE_EXPIRE_SOON}_{days_before}d"
|
||||
|
||||
async with self._session_factory() as session:
|
||||
if await self._already_notified(
|
||||
session,
|
||||
remnawave_user_id=rw_user_id,
|
||||
notification_type=notification_type,
|
||||
expire_at_snapshot=expire_at,
|
||||
):
|
||||
return False
|
||||
|
||||
text = self._build_notification_text(
|
||||
brand_name=brand_name,
|
||||
expire_at=expire_at,
|
||||
days_before=days_before,
|
||||
)
|
||||
|
||||
try:
|
||||
if image_path is not None:
|
||||
await bot.send_photo(
|
||||
chat_id=telegram_id,
|
||||
photo=FSInputFile(str(image_path)),
|
||||
caption=text,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=text,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Cannot send notification to telegram_id=%s (blocked or unavailable)",
|
||||
telegram_id,
|
||||
)
|
||||
return False
|
||||
|
||||
await self._record_notification(
|
||||
session,
|
||||
remnawave_user_id=rw_user_id,
|
||||
telegram_id=telegram_id,
|
||||
notification_type=notification_type,
|
||||
expire_at_snapshot=expire_at,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _build_notification_text(
|
||||
*,
|
||||
brand_name: str,
|
||||
expire_at: datetime,
|
||||
days_before: int,
|
||||
) -> str:
|
||||
import html as html_module
|
||||
|
||||
if days_before <= 1:
|
||||
urgency = "⚠️ Ваша подписка истекает <b>завтра</b>!"
|
||||
else:
|
||||
urgency = f"⏰ Ваша подписка истекает через <b>{days_before} дн.</b>"
|
||||
|
||||
lines = [
|
||||
f"<b>{html_module.escape(brand_name)}</b>",
|
||||
"",
|
||||
urgency,
|
||||
"",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"Дата истечения: <b>{format_datetime_with_days_left(expire_at)}</b>\n\n"
|
||||
"Чтобы продлить доступ, откройте бот и выберите тариф."
|
||||
"</blockquote>"
|
||||
),
|
||||
"",
|
||||
"<i>Нажмите /start чтобы открыть меню и продлить подписку.</i>",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
async def run_periodic(self, bot: Bot, *, default_interval_hours: int = 6) -> None:
|
||||
logger.info("Subscription notification periodic task started")
|
||||
while True:
|
||||
try:
|
||||
config = await self._get_config()
|
||||
interval_hours = max(config.notification_check_interval_hours, 1)
|
||||
except Exception:
|
||||
interval_hours = max(default_interval_hours, 1)
|
||||
|
||||
try:
|
||||
total = await self.check_and_notify(bot)
|
||||
if total > 0:
|
||||
logger.info("Sent %d subscription expiration notifications", total)
|
||||
except Exception:
|
||||
logger.exception("Error in subscription notification check")
|
||||
|
||||
await asyncio.sleep(interval_hours * 3600)
|
||||
983
app/services/payment_service.py
Normal file
983
app/services/payment_service.py
Normal file
@@ -0,0 +1,983 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import ceil
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.config import PaymentPlanSettings, Settings
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import PaymentOrder, ReferralBonus, ReferralInvite, TelegramUser
|
||||
from app.schemas.remnawave import RemnawaveUser
|
||||
from app.services.bot_config_service import BotConfigSnapshot, StaticBotConfigService
|
||||
from app.services.remnawave_client import RemnawaveApiClient, RemnawaveApiError
|
||||
from app.services.sync_service import SyncService
|
||||
|
||||
|
||||
PENDING_PAYMENT_STATUS = "PENDING"
|
||||
REVIEW_PAYMENT_STATUS = "REVIEW"
|
||||
PROCESSING_PAYMENT_STATUS = "PROCESSING"
|
||||
REJECTED_PAYMENT_STATUS = "REJECTED"
|
||||
FULFILLED_PAYMENT_STATUS = "FULFILLED"
|
||||
REFERRAL_BONUS_PENDING_STATUS = "PENDING"
|
||||
REFERRAL_BONUS_PROCESSING_STATUS = "PROCESSING"
|
||||
REFERRAL_BONUS_APPLIED_STATUS = "APPLIED"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PaymentPlan:
|
||||
code: str
|
||||
title: str
|
||||
description: str
|
||||
duration_days: int
|
||||
amount_rub: int
|
||||
discount_percent: int
|
||||
original_amount_rub: int
|
||||
applied_referral_code: str = ""
|
||||
traffic_limit_bytes: int = 0
|
||||
traffic_limit_strategy: str = "NO_RESET"
|
||||
internal_squad_uuids: list[str] | None = None
|
||||
external_squad_uuid: str | None = None
|
||||
|
||||
@property
|
||||
def is_ready(self) -> bool:
|
||||
return bool(self.internal_squad_uuids) and self.duration_days > 0 and self.amount_rub > 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CreatedPaymentOrder:
|
||||
order_uuid: str
|
||||
provision_username: str
|
||||
plan: PaymentPlan
|
||||
transfer_text: str
|
||||
extends_existing_access: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StoredPaymentOrder:
|
||||
order_uuid: str
|
||||
telegram_id: int
|
||||
plan_title: str
|
||||
plan_duration_days: int
|
||||
amount_rub: int
|
||||
provision_username: str
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class IssuedAccess:
|
||||
telegram_id: int
|
||||
username: str
|
||||
subscription_url: str
|
||||
expire_at: datetime
|
||||
short_uuid: str
|
||||
remnawave_user_uuid: str
|
||||
traffic_limit_bytes: int
|
||||
referral_bonus: ReferralBonusGrant | None = None
|
||||
is_renewal: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ReferralBonusGrant:
|
||||
inviter_telegram_id: int
|
||||
bonus_days: int
|
||||
status: str
|
||||
total_applied_days: int = 0
|
||||
expire_at: datetime | None = None
|
||||
applied_to_user_uuid: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppliedReferralBonus:
|
||||
telegram_id: int
|
||||
total_bonus_days: int
|
||||
expire_at: datetime
|
||||
applied_to_user_uuid: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PaymentAdminStats:
|
||||
pending_orders: int = 0
|
||||
review_orders: int = 0
|
||||
pending_referral_bonuses: int = 0
|
||||
|
||||
|
||||
class PaymentService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Settings,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
remnawave_client: RemnawaveApiClient,
|
||||
sync_service: SyncService,
|
||||
config_service: StaticBotConfigService | None = None,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._session_factory = session_factory
|
||||
self._remnawave_client = remnawave_client
|
||||
self._sync_service = sync_service
|
||||
self._config_service = config_service or StaticBotConfigService(settings)
|
||||
|
||||
async def _get_config(self) -> BotConfigSnapshot:
|
||||
return await self._config_service.get_snapshot()
|
||||
|
||||
async def get_available_plans(self, *, telegram_id: int) -> list[PaymentPlan]:
|
||||
config = await self._get_config()
|
||||
applied_referral_code = await self._get_applied_referral_code(
|
||||
telegram_id,
|
||||
config=config,
|
||||
)
|
||||
return [
|
||||
self._build_plan(
|
||||
plan,
|
||||
applied_referral_code=applied_referral_code,
|
||||
config=config,
|
||||
)
|
||||
for plan in config.payment_plans
|
||||
]
|
||||
|
||||
async def create_order(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
plan_code: str,
|
||||
) -> CreatedPaymentOrder:
|
||||
config = await self._get_config()
|
||||
applied_referral_code = await self._get_applied_referral_code(
|
||||
telegram_id,
|
||||
config=config,
|
||||
)
|
||||
plan = self._get_plan_by_code(
|
||||
plan_code,
|
||||
applied_referral_code=applied_referral_code,
|
||||
config=config,
|
||||
)
|
||||
if not plan.is_ready:
|
||||
raise ValueError(
|
||||
"Покупка не настроена. Проверьте PAYMENT_PLANS и PAYMENT_INTERNAL_SQUAD_UUIDS."
|
||||
)
|
||||
|
||||
existing_accesses = await self._sync_service.get_cached_users_for_telegram(telegram_id)
|
||||
order_uuid = str(uuid4())
|
||||
invoice_payload = f"manual:{plan.code}:{order_uuid}"
|
||||
provision_username = self._build_provision_username(
|
||||
config=config,
|
||||
order_uuid=order_uuid,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
user = await self._upsert_telegram_user(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
session.add(
|
||||
PaymentOrder(
|
||||
order_uuid=order_uuid,
|
||||
telegram_user_id=user.id,
|
||||
telegram_id=telegram_id,
|
||||
plan_code=plan.code,
|
||||
plan_title=plan.title,
|
||||
plan_duration_days=plan.duration_days,
|
||||
traffic_limit_bytes=plan.traffic_limit_bytes,
|
||||
traffic_limit_strategy=plan.traffic_limit_strategy,
|
||||
amount_stars=plan.amount_rub,
|
||||
currency="RUB",
|
||||
status=PENDING_PAYMENT_STATUS,
|
||||
invoice_payload=invoice_payload,
|
||||
provision_username=provision_username,
|
||||
error_message=applied_referral_code or None,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return CreatedPaymentOrder(
|
||||
order_uuid=order_uuid,
|
||||
provision_username=provision_username,
|
||||
plan=plan,
|
||||
transfer_text=config.payment_transfer_text.strip(),
|
||||
extends_existing_access=bool(existing_accesses),
|
||||
)
|
||||
|
||||
async def get_order_for_user(self, *, order_uuid: str, telegram_id: int) -> StoredPaymentOrder:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder).where(
|
||||
PaymentOrder.order_uuid == order_uuid,
|
||||
PaymentOrder.telegram_id == telegram_id,
|
||||
)
|
||||
)
|
||||
if order is None:
|
||||
raise ValueError("Заказ не найден.")
|
||||
|
||||
return StoredPaymentOrder(
|
||||
order_uuid=order.order_uuid,
|
||||
telegram_id=order.telegram_id,
|
||||
plan_title=order.plan_title,
|
||||
plan_duration_days=order.plan_duration_days,
|
||||
amount_rub=order.amount_stars,
|
||||
provision_username=order.provision_username,
|
||||
status=order.status,
|
||||
)
|
||||
|
||||
async def mark_order_under_review(self, *, order_uuid: str, telegram_id: int) -> StoredPaymentOrder:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder)
|
||||
.where(
|
||||
PaymentOrder.order_uuid == order_uuid,
|
||||
PaymentOrder.telegram_id == telegram_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if order is None:
|
||||
raise ValueError("Заказ не найден.")
|
||||
if order.status == REVIEW_PAYMENT_STATUS:
|
||||
raise ValueError("Чек уже отправлен на проверку.")
|
||||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже обрабатывается модератором.")
|
||||
if order.status == FULFILLED_PAYMENT_STATUS:
|
||||
raise ValueError("Этот заказ уже подтверждён.")
|
||||
if order.status == REJECTED_PAYMENT_STATUS:
|
||||
raise ValueError("Этот заказ уже отклонён. Создайте новый платёж.")
|
||||
if order.status != PENDING_PAYMENT_STATUS:
|
||||
raise ValueError("Некорректный статус заказа для отправки чека.")
|
||||
|
||||
order.status = REVIEW_PAYMENT_STATUS
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
return StoredPaymentOrder(
|
||||
order_uuid=order.order_uuid,
|
||||
telegram_id=order.telegram_id,
|
||||
plan_title=order.plan_title,
|
||||
plan_duration_days=order.plan_duration_days,
|
||||
amount_rub=order.amount_stars,
|
||||
provision_username=order.provision_username,
|
||||
status=order.status,
|
||||
)
|
||||
|
||||
async def revert_order_to_pending(self, *, order_uuid: str, telegram_id: int) -> None:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder)
|
||||
.where(
|
||||
PaymentOrder.order_uuid == order_uuid,
|
||||
PaymentOrder.telegram_id == telegram_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if order is None or order.status != REVIEW_PAYMENT_STATUS:
|
||||
return
|
||||
|
||||
order.status = PENDING_PAYMENT_STATUS
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def approve_order(self, *, order_uuid: str) -> IssuedAccess:
|
||||
order = await self._claim_order_for_approval(order_uuid)
|
||||
config = await self._get_config()
|
||||
is_renewal = False
|
||||
|
||||
try:
|
||||
remote_user: RemnawaveUser | None = None
|
||||
if order.remnawave_user_uuid:
|
||||
try:
|
||||
remote_user = await self._remnawave_client.get_user_by_uuid(
|
||||
order.remnawave_user_uuid
|
||||
)
|
||||
except RemnawaveApiError as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
remote_user = None
|
||||
|
||||
if remote_user is None:
|
||||
try:
|
||||
remote_user = await self._remnawave_client.get_user_by_username(
|
||||
order.provision_username
|
||||
)
|
||||
except RemnawaveApiError as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
|
||||
if remote_user is None:
|
||||
remote_users = await self._remnawave_client.get_users_by_telegram_id(
|
||||
order.telegram_id
|
||||
)
|
||||
remote_user = self._select_referral_bonus_target(remote_users)
|
||||
|
||||
if remote_user is None:
|
||||
remote_user = await self._create_remnawave_user(order, config=config)
|
||||
else:
|
||||
is_renewal = True
|
||||
remote_user = await self._extend_remnawave_user(
|
||||
order,
|
||||
remote_user,
|
||||
config=config,
|
||||
)
|
||||
except RemnawaveApiError as exc:
|
||||
await self._restore_review_after_failed_approval(order.order_uuid, exc.message)
|
||||
raise
|
||||
|
||||
await self._save_fulfilled_order(order.order_uuid, remote_user)
|
||||
own_bonus = None
|
||||
try:
|
||||
own_bonus = await self.apply_pending_referral_bonuses(
|
||||
telegram_id=order.telegram_id,
|
||||
refresh_cache=False,
|
||||
)
|
||||
except RemnawaveApiError as exc:
|
||||
logger.warning(
|
||||
"Could not apply pending referral bonus for telegram_id=%s: %s",
|
||||
order.telegram_id,
|
||||
exc.message,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._sync_service.refresh_cached_users_for_telegram(
|
||||
telegram_id=order.telegram_id,
|
||||
)
|
||||
except RemnawaveApiError:
|
||||
# Access is already issued at this point; cache can be refreshed later.
|
||||
pass
|
||||
|
||||
expire_at = remote_user.expire_at
|
||||
if own_bonus is not None and own_bonus.applied_to_user_uuid == str(remote_user.uuid):
|
||||
expire_at = own_bonus.expire_at
|
||||
|
||||
referral_bonus = await self._grant_referral_bonus(order, config=config)
|
||||
|
||||
return IssuedAccess(
|
||||
telegram_id=order.telegram_id,
|
||||
username=remote_user.username,
|
||||
subscription_url=remote_user.subscription_url,
|
||||
expire_at=expire_at,
|
||||
short_uuid=remote_user.short_uuid,
|
||||
remnawave_user_uuid=str(remote_user.uuid),
|
||||
traffic_limit_bytes=remote_user.traffic_limit_bytes,
|
||||
referral_bonus=referral_bonus,
|
||||
is_renewal=is_renewal,
|
||||
)
|
||||
|
||||
async def reject_order(self, *, order_uuid: str, reason: str | None = None) -> StoredPaymentOrder:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder)
|
||||
.where(PaymentOrder.order_uuid == order_uuid)
|
||||
.with_for_update()
|
||||
)
|
||||
if order is None:
|
||||
raise ValueError("Заказ не найден.")
|
||||
if order.status == FULFILLED_PAYMENT_STATUS:
|
||||
raise ValueError("Подтверждённый заказ нельзя отклонить.")
|
||||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже обрабатывается другим модератором.")
|
||||
if order.status == PENDING_PAYMENT_STATUS:
|
||||
raise ValueError("Чек ещё не отправлен на проверку.")
|
||||
if order.status == REJECTED_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже отклонён.")
|
||||
|
||||
order.status = REJECTED_PAYMENT_STATUS
|
||||
order.error_message = reason or "Платёж отклонён"
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
return StoredPaymentOrder(
|
||||
order_uuid=order.order_uuid,
|
||||
telegram_id=order.telegram_id,
|
||||
plan_title=order.plan_title,
|
||||
plan_duration_days=order.plan_duration_days,
|
||||
amount_rub=order.amount_stars,
|
||||
provision_username=order.provision_username,
|
||||
status=order.status,
|
||||
)
|
||||
|
||||
async def get_referral_code_for_order(self, *, order_uuid: str) -> str:
|
||||
order = await self._get_order(order_uuid)
|
||||
return order.error_message or ""
|
||||
|
||||
def build_transfer_text(self, *, order: CreatedPaymentOrder) -> str:
|
||||
lines = [
|
||||
"<b>Оплата тарифа</b>",
|
||||
"",
|
||||
f"<b>{html.escape(order.plan.title)}</b>",
|
||||
html.escape(order.plan.description),
|
||||
"",
|
||||
f"Сумма к переводу: <b>{order.plan.amount_rub} ₽</b>",
|
||||
]
|
||||
if order.plan.applied_referral_code:
|
||||
lines.append(
|
||||
f"Реферальный код: <code>{order.plan.applied_referral_code}</code> "
|
||||
f"(<b>-{order.plan.discount_percent}%</b>)"
|
||||
)
|
||||
if order.extends_existing_access:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
(
|
||||
"<blockquote>"
|
||||
"У вас уже есть доступ. После подтверждения оплаты бот автоматически "
|
||||
"продлит текущую подписку на выбранный срок."
|
||||
"</blockquote>"
|
||||
),
|
||||
]
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<b>Реквизиты</b>",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"{html.escape(order.transfer_text or 'Укажите реквизиты в PAYMENT_TRANSFER_TEXT.')}"
|
||||
"</blockquote>"
|
||||
),
|
||||
"",
|
||||
"После перевода нажмите кнопку ниже и отправьте чек следующим сообщением.",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_plan_by_code(
|
||||
self,
|
||||
plan_code: str,
|
||||
*,
|
||||
applied_referral_code: str,
|
||||
config: BotConfigSnapshot,
|
||||
) -> PaymentPlan:
|
||||
for plan in config.payment_plans:
|
||||
if plan.code == plan_code:
|
||||
return self._build_plan(
|
||||
plan,
|
||||
applied_referral_code=applied_referral_code,
|
||||
config=config,
|
||||
)
|
||||
|
||||
raise ValueError("Тариф не найден.")
|
||||
|
||||
def _build_plan(
|
||||
self,
|
||||
plan_settings: PaymentPlanSettings,
|
||||
*,
|
||||
applied_referral_code: str,
|
||||
config: BotConfigSnapshot,
|
||||
) -> PaymentPlan:
|
||||
discount_percent = max(config.referral_discount_percent, 0) if applied_referral_code else 0
|
||||
discounted_amount = self._apply_discount(
|
||||
amount_rub=plan_settings.amount_rub,
|
||||
discount_percent=discount_percent,
|
||||
)
|
||||
traffic_limit_bytes = max(config.payment_plan_traffic_limit_gb, 0) * 1024**3
|
||||
return PaymentPlan(
|
||||
code=plan_settings.code,
|
||||
title=f"{config.bot_brand_name} на {plan_settings.days} дней",
|
||||
description=f"Доступ к VPN на {plan_settings.days} дней",
|
||||
duration_days=plan_settings.days,
|
||||
amount_rub=discounted_amount,
|
||||
discount_percent=discount_percent,
|
||||
original_amount_rub=plan_settings.amount_rub,
|
||||
applied_referral_code=applied_referral_code,
|
||||
traffic_limit_bytes=traffic_limit_bytes,
|
||||
traffic_limit_strategy=config.payment_plan_traffic_reset_period.strip() or "NO_RESET",
|
||||
internal_squad_uuids=config.payment_internal_squad_uuids,
|
||||
external_squad_uuid=config.payment_external_squad_uuid_normalized,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _apply_discount(*, amount_rub: int, discount_percent: int) -> int:
|
||||
if discount_percent <= 0:
|
||||
return amount_rub
|
||||
return max(1, ceil(amount_rub * (100 - discount_percent) / 100))
|
||||
|
||||
async def _get_applied_referral_code(
|
||||
self,
|
||||
telegram_id: int,
|
||||
*,
|
||||
config: BotConfigSnapshot | None = None,
|
||||
) -> str:
|
||||
resolved_config = config or await self._get_config()
|
||||
if not resolved_config.referral_enabled:
|
||||
return ""
|
||||
summary = await self._sync_service.get_referral_summary(telegram_id)
|
||||
return summary.applied_referral_code.strip().upper()
|
||||
|
||||
async def apply_pending_referral_bonuses(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
refresh_cache: bool = True,
|
||||
) -> AppliedReferralBonus | None:
|
||||
config = await self._get_config()
|
||||
if not config.referral_enabled:
|
||||
return None
|
||||
|
||||
claimed_bonuses = await self._claim_pending_referral_bonuses(telegram_id)
|
||||
if not claimed_bonuses:
|
||||
return None
|
||||
|
||||
total_bonus_days = sum(max(bonus.bonus_days, 0) for bonus in claimed_bonuses)
|
||||
if total_bonus_days <= 0:
|
||||
await self._restore_claimed_referral_bonuses(
|
||||
[bonus.id for bonus in claimed_bonuses],
|
||||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
return None
|
||||
|
||||
remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)
|
||||
target_user = self._select_referral_bonus_target(remote_users)
|
||||
if target_user is None:
|
||||
await self._restore_claimed_referral_bonuses(
|
||||
[bonus.id for bonus in claimed_bonuses],
|
||||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
return None
|
||||
|
||||
current_expire_at = target_user.expire_at
|
||||
if current_expire_at.tzinfo is None:
|
||||
current_expire_at = current_expire_at.replace(tzinfo=timezone.utc)
|
||||
next_expire_at = max(current_expire_at, datetime.now(timezone.utc)) + timedelta(
|
||||
days=total_bonus_days
|
||||
)
|
||||
|
||||
try:
|
||||
updated_user = await self._remnawave_client.update_user(
|
||||
{
|
||||
"uuid": str(target_user.uuid),
|
||||
"expireAt": next_expire_at.isoformat().replace("+00:00", "Z"),
|
||||
"description": (
|
||||
f"Referral bonus applied by Telegram bot "
|
||||
f"({total_bonus_days} days)"
|
||||
),
|
||||
}
|
||||
)
|
||||
except RemnawaveApiError:
|
||||
await self._restore_claimed_referral_bonuses(
|
||||
[bonus.id for bonus in claimed_bonuses],
|
||||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
raise
|
||||
|
||||
await self._mark_referral_bonuses_applied(
|
||||
[bonus.id for bonus in claimed_bonuses],
|
||||
applied_to_user_uuid=str(updated_user.uuid),
|
||||
)
|
||||
|
||||
if refresh_cache:
|
||||
try:
|
||||
await self._sync_service.refresh_cached_users_for_telegram(
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
except RemnawaveApiError:
|
||||
pass
|
||||
|
||||
return AppliedReferralBonus(
|
||||
telegram_id=telegram_id,
|
||||
total_bonus_days=total_bonus_days,
|
||||
expire_at=updated_user.expire_at,
|
||||
applied_to_user_uuid=str(updated_user.uuid),
|
||||
)
|
||||
|
||||
async def get_admin_stats(self) -> PaymentAdminStats:
|
||||
async with self._session_factory() as session:
|
||||
pending_orders = await session.scalar(
|
||||
select(func.count(PaymentOrder.id)).where(
|
||||
PaymentOrder.status == PENDING_PAYMENT_STATUS
|
||||
)
|
||||
)
|
||||
review_orders = await session.scalar(
|
||||
select(func.count(PaymentOrder.id)).where(
|
||||
PaymentOrder.status == REVIEW_PAYMENT_STATUS
|
||||
)
|
||||
)
|
||||
pending_referral_bonuses = await session.scalar(
|
||||
select(func.count(ReferralBonus.id)).where(
|
||||
ReferralBonus.status == REFERRAL_BONUS_PENDING_STATUS
|
||||
)
|
||||
)
|
||||
|
||||
return PaymentAdminStats(
|
||||
pending_orders=int(pending_orders or 0),
|
||||
review_orders=int(review_orders or 0),
|
||||
pending_referral_bonuses=int(pending_referral_bonuses or 0),
|
||||
)
|
||||
|
||||
async def _get_order(self, order_uuid: str) -> PaymentOrder:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
|
||||
)
|
||||
if order is None:
|
||||
raise ValueError("Заказ не найден.")
|
||||
return order
|
||||
|
||||
async def _claim_order_for_approval(self, order_uuid: str) -> PaymentOrder:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder)
|
||||
.where(PaymentOrder.order_uuid == order_uuid)
|
||||
.with_for_update()
|
||||
)
|
||||
if order is None:
|
||||
raise ValueError("Заказ не найден.")
|
||||
if order.status == PENDING_PAYMENT_STATUS:
|
||||
raise ValueError("Чек ещё не отправлен на проверку.")
|
||||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже обрабатывается другим модератором.")
|
||||
if order.status == REJECTED_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже отклонён.")
|
||||
if order.status == FULFILLED_PAYMENT_STATUS:
|
||||
raise ValueError("Этот заказ уже подтверждён.")
|
||||
if order.status != REVIEW_PAYMENT_STATUS:
|
||||
raise ValueError("Некорректный статус заказа для подтверждения.")
|
||||
|
||||
order.status = PROCESSING_PAYMENT_STATUS
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
return order
|
||||
|
||||
async def _create_remnawave_user(
|
||||
self,
|
||||
order: PaymentOrder,
|
||||
*,
|
||||
config: BotConfigSnapshot,
|
||||
) -> RemnawaveUser:
|
||||
expire_at = datetime.now(timezone.utc) + timedelta(days=order.plan_duration_days)
|
||||
body: dict[str, object] = {
|
||||
"username": order.provision_username,
|
||||
"expireAt": expire_at.isoformat().replace("+00:00", "Z"),
|
||||
"trafficLimitBytes": order.traffic_limit_bytes,
|
||||
"trafficLimitStrategy": order.traffic_limit_strategy,
|
||||
"telegramId": order.telegram_id,
|
||||
"description": f"Created by Telegram bot manual payment {order.order_uuid}",
|
||||
"activeInternalSquads": config.payment_internal_squad_uuids,
|
||||
}
|
||||
if config.payment_user_tag_normalized:
|
||||
body["tag"] = config.payment_user_tag_normalized
|
||||
if config.payment_external_squad_uuid_normalized:
|
||||
body["externalSquadUuid"] = config.payment_external_squad_uuid_normalized
|
||||
return await self._remnawave_client.create_user(body)
|
||||
|
||||
async def _extend_remnawave_user(
|
||||
self,
|
||||
order: PaymentOrder,
|
||||
remote_user: RemnawaveUser,
|
||||
*,
|
||||
config: BotConfigSnapshot,
|
||||
) -> RemnawaveUser:
|
||||
current_expire_at = remote_user.expire_at
|
||||
if current_expire_at.tzinfo is None:
|
||||
current_expire_at = current_expire_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
base_expire_at = max(current_expire_at, datetime.now(timezone.utc))
|
||||
next_expire_at = base_expire_at + timedelta(days=order.plan_duration_days)
|
||||
|
||||
traffic_limit_bytes = order.traffic_limit_bytes
|
||||
if traffic_limit_bytes <= 0:
|
||||
traffic_limit_bytes = remote_user.traffic_limit_bytes
|
||||
|
||||
body: dict[str, object] = {
|
||||
"uuid": str(remote_user.uuid),
|
||||
"username": remote_user.username,
|
||||
"expireAt": next_expire_at.isoformat().replace("+00:00", "Z"),
|
||||
"trafficLimitBytes": traffic_limit_bytes,
|
||||
"trafficLimitStrategy": order.traffic_limit_strategy,
|
||||
"telegramId": order.telegram_id,
|
||||
"description": f"Updated by Telegram bot manual payment {order.order_uuid}",
|
||||
"activeInternalSquads": config.payment_internal_squad_uuids,
|
||||
}
|
||||
if config.payment_user_tag_normalized:
|
||||
body["tag"] = config.payment_user_tag_normalized
|
||||
if config.payment_external_squad_uuid_normalized:
|
||||
body["externalSquadUuid"] = config.payment_external_squad_uuid_normalized
|
||||
return await self._remnawave_client.update_user(body)
|
||||
|
||||
async def _save_fulfilled_order(self, order_uuid: str, remote_user: RemnawaveUser) -> None:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
|
||||
)
|
||||
if order is None:
|
||||
return
|
||||
order.status = FULFILLED_PAYMENT_STATUS
|
||||
order.remnawave_user_uuid = str(remote_user.uuid)
|
||||
order.subscription_url = remote_user.subscription_url
|
||||
order.error_message = None
|
||||
order.paid_at = utcnow()
|
||||
order.fulfilled_at = utcnow()
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def _save_order_error(self, order_uuid: str, error_message: str) -> None:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
|
||||
)
|
||||
if order is None:
|
||||
return
|
||||
order.error_message = error_message
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def _restore_review_after_failed_approval(
|
||||
self,
|
||||
order_uuid: str,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder)
|
||||
.where(PaymentOrder.order_uuid == order_uuid)
|
||||
.with_for_update()
|
||||
)
|
||||
if order is None:
|
||||
return
|
||||
|
||||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||||
order.status = REVIEW_PAYMENT_STATUS
|
||||
order.error_message = error_message
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def _grant_referral_bonus(
|
||||
self,
|
||||
order: PaymentOrder,
|
||||
*,
|
||||
config: BotConfigSnapshot,
|
||||
) -> ReferralBonusGrant | None:
|
||||
if not config.referral_enabled:
|
||||
return None
|
||||
|
||||
bonus_days = max(config.referral_bonus_days, 0)
|
||||
if bonus_days <= 0:
|
||||
return None
|
||||
|
||||
inviter_telegram_id = await self._get_inviter_telegram_id(order.telegram_id)
|
||||
if inviter_telegram_id is None or inviter_telegram_id == order.telegram_id:
|
||||
return None
|
||||
|
||||
await self._create_referral_bonus(
|
||||
inviter_telegram_id=inviter_telegram_id,
|
||||
invited_telegram_id=order.telegram_id,
|
||||
source_order_uuid=order.order_uuid,
|
||||
bonus_days=bonus_days,
|
||||
)
|
||||
|
||||
try:
|
||||
applied_bonus = await self.apply_pending_referral_bonuses(
|
||||
telegram_id=inviter_telegram_id,
|
||||
)
|
||||
except RemnawaveApiError as exc:
|
||||
logger.warning(
|
||||
"Could not apply referral bonus for inviter=%s after order=%s: %s",
|
||||
inviter_telegram_id,
|
||||
order.order_uuid,
|
||||
exc.message,
|
||||
)
|
||||
applied_bonus = None
|
||||
|
||||
if applied_bonus is None:
|
||||
return ReferralBonusGrant(
|
||||
inviter_telegram_id=inviter_telegram_id,
|
||||
bonus_days=bonus_days,
|
||||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
|
||||
return ReferralBonusGrant(
|
||||
inviter_telegram_id=inviter_telegram_id,
|
||||
bonus_days=bonus_days,
|
||||
status=REFERRAL_BONUS_APPLIED_STATUS,
|
||||
total_applied_days=applied_bonus.total_bonus_days,
|
||||
expire_at=applied_bonus.expire_at,
|
||||
applied_to_user_uuid=applied_bonus.applied_to_user_uuid,
|
||||
)
|
||||
|
||||
async def _get_inviter_telegram_id(self, invited_telegram_id: int) -> int | None:
|
||||
async with self._session_factory() as session:
|
||||
return await session.scalar(
|
||||
select(ReferralInvite.inviter_telegram_id).where(
|
||||
ReferralInvite.invited_telegram_id == invited_telegram_id
|
||||
)
|
||||
)
|
||||
|
||||
async def _create_referral_bonus(
|
||||
self,
|
||||
*,
|
||||
inviter_telegram_id: int,
|
||||
invited_telegram_id: int,
|
||||
source_order_uuid: str,
|
||||
bonus_days: int,
|
||||
) -> None:
|
||||
async with self._session_factory() as session:
|
||||
existing = await session.scalar(
|
||||
select(ReferralBonus).where(
|
||||
ReferralBonus.source_order_uuid == source_order_uuid
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
return
|
||||
|
||||
session.add(
|
||||
ReferralBonus(
|
||||
inviter_telegram_id=inviter_telegram_id,
|
||||
invited_telegram_id=invited_telegram_id,
|
||||
source_order_uuid=source_order_uuid,
|
||||
bonus_days=bonus_days,
|
||||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def _claim_pending_referral_bonuses(self, telegram_id: int) -> list[ReferralBonus]:
|
||||
async with self._session_factory() as session:
|
||||
bonuses = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(ReferralBonus)
|
||||
.where(
|
||||
ReferralBonus.inviter_telegram_id == telegram_id,
|
||||
ReferralBonus.status == REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
.order_by(ReferralBonus.created_at.asc(), ReferralBonus.id.asc())
|
||||
.with_for_update()
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if not bonuses:
|
||||
return []
|
||||
|
||||
for bonus in bonuses:
|
||||
bonus.status = REFERRAL_BONUS_PROCESSING_STATUS
|
||||
bonus.updated_at = utcnow()
|
||||
await session.commit()
|
||||
return bonuses
|
||||
|
||||
async def _restore_claimed_referral_bonuses(
|
||||
self,
|
||||
bonus_ids: list[int],
|
||||
*,
|
||||
status: str,
|
||||
) -> None:
|
||||
if not bonus_ids:
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
bonuses = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(ReferralBonus)
|
||||
.where(ReferralBonus.id.in_(bonus_ids))
|
||||
.with_for_update()
|
||||
)
|
||||
).all()
|
||||
)
|
||||
for bonus in bonuses:
|
||||
bonus.status = status
|
||||
bonus.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def _mark_referral_bonuses_applied(
|
||||
self,
|
||||
bonus_ids: list[int],
|
||||
*,
|
||||
applied_to_user_uuid: str,
|
||||
) -> None:
|
||||
if not bonus_ids:
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
bonuses = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(ReferralBonus)
|
||||
.where(ReferralBonus.id.in_(bonus_ids))
|
||||
.with_for_update()
|
||||
)
|
||||
).all()
|
||||
)
|
||||
for bonus in bonuses:
|
||||
bonus.status = REFERRAL_BONUS_APPLIED_STATUS
|
||||
bonus.applied_to_user_uuid = applied_to_user_uuid
|
||||
bonus.applied_at = utcnow()
|
||||
bonus.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
@staticmethod
|
||||
def _select_referral_bonus_target(
|
||||
remote_users: list[RemnawaveUser],
|
||||
) -> RemnawaveUser | None:
|
||||
if not remote_users:
|
||||
return None
|
||||
|
||||
def _sort_key(user: RemnawaveUser) -> tuple[int, datetime, str]:
|
||||
expire_at = user.expire_at
|
||||
if expire_at.tzinfo is None:
|
||||
expire_at = expire_at.replace(tzinfo=timezone.utc)
|
||||
return (0 if user.status.upper() == "ACTIVE" else 1, expire_at, user.username)
|
||||
|
||||
return sorted(remote_users, key=_sort_key)[0]
|
||||
|
||||
async def _upsert_telegram_user(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> TelegramUser:
|
||||
record = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
if record is None:
|
||||
record = TelegramUser(telegram_id=telegram_id)
|
||||
session.add(record)
|
||||
|
||||
record.username = username
|
||||
record.first_name = first_name
|
||||
record.last_name = last_name
|
||||
record.language_code = language_code
|
||||
record.is_admin = self._settings.is_admin(telegram_id)
|
||||
record.last_seen_at = utcnow()
|
||||
record.updated_at = utcnow()
|
||||
|
||||
await session.flush()
|
||||
return record
|
||||
|
||||
def _build_provision_username(
|
||||
self,
|
||||
*,
|
||||
config: BotConfigSnapshot,
|
||||
order_uuid: str,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
) -> str:
|
||||
base_name = username or first_name or "user"
|
||||
normalized_name = "".join(
|
||||
ch for ch in base_name if ch.isalnum() or ch in "_-"
|
||||
).strip("-_")
|
||||
if not normalized_name:
|
||||
normalized_name = "user"
|
||||
|
||||
prefix = config.payment_username_prefix_normalized or "Oreol"
|
||||
unique_suffix = order_uuid.replace("-", "")[:6]
|
||||
provision_username = f"{prefix}-{telegram_id}-{unique_suffix}-{normalized_name}"
|
||||
return provision_username[:36]
|
||||
224
app/services/remnawave_client.py
Normal file
224
app/services/remnawave_client.py
Normal file
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.schemas.remnawave import PaginatedUsers, RemnawaveUser, ResolvedUser, SubscriptionRequestHistory
|
||||
|
||||
|
||||
UUID_RE = re.compile(
|
||||
r"^[0-9a-fA-F]{8}-"
|
||||
r"[0-9a-fA-F]{4}-"
|
||||
r"[0-9a-fA-F]{4}-"
|
||||
r"[0-9a-fA-F]{4}-"
|
||||
r"[0-9a-fA-F]{12}$"
|
||||
)
|
||||
|
||||
|
||||
class RemnawaveApiError(RuntimeError):
|
||||
def __init__(self, *, status_code: int, message: str, payload: Any | None = None) -> None:
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.payload = payload
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class RemnawaveApiClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_token: str,
|
||||
caddy_api_key: str = "",
|
||||
timeout_seconds: float = 20.0,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
self._managed_client = client is None
|
||||
self._client = client or httpx.AsyncClient(
|
||||
base_url=self.normalize_base_url(base_url),
|
||||
timeout=timeout_seconds,
|
||||
headers=self._build_headers(
|
||||
base_url=base_url,
|
||||
api_token=api_token,
|
||||
caddy_api_key=caddy_api_key,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def normalize_base_url(base_url: str) -> str:
|
||||
normalized = base_url.strip().rstrip("/")
|
||||
if not normalized.endswith("/api"):
|
||||
normalized = f"{normalized}/api"
|
||||
return f"{normalized}/"
|
||||
|
||||
@staticmethod
|
||||
def unwrap_payload(payload: Any) -> Any:
|
||||
if isinstance(payload, Mapping) and "response" in payload:
|
||||
return payload["response"]
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _build_headers(
|
||||
*,
|
||||
base_url: str,
|
||||
api_token: str,
|
||||
caddy_api_key: str,
|
||||
) -> dict[str, str]:
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
if api_token:
|
||||
headers["Authorization"] = (
|
||||
api_token if api_token.startswith("Bearer ") else f"Bearer {api_token}"
|
||||
)
|
||||
if caddy_api_key:
|
||||
headers["X-Api-Key"] = caddy_api_key
|
||||
if base_url.startswith("http://"):
|
||||
headers["x-forwarded-proto"] = "https"
|
||||
headers["x-forwarded-for"] = "127.0.0.1"
|
||||
return headers
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._managed_client:
|
||||
await self._client.aclose()
|
||||
|
||||
async def get_users_by_telegram_id(self, telegram_id: int) -> list[RemnawaveUser]:
|
||||
payload = await self._request_json("GET", f"users/by-telegram-id/{telegram_id}")
|
||||
return [RemnawaveUser.model_validate(item) for item in payload]
|
||||
|
||||
async def get_user_by_uuid(self, user_uuid: str) -> RemnawaveUser:
|
||||
payload = await self._request_json("GET", f"users/{user_uuid}")
|
||||
return RemnawaveUser.model_validate(payload)
|
||||
|
||||
async def get_user_by_short_uuid(self, short_uuid: str) -> RemnawaveUser:
|
||||
payload = await self._request_json("GET", f"users/by-short-uuid/{short_uuid}")
|
||||
return RemnawaveUser.model_validate(payload)
|
||||
|
||||
async def get_user_by_username(self, username: str) -> RemnawaveUser:
|
||||
payload = await self._request_json("GET", f"users/by-username/{username}")
|
||||
return RemnawaveUser.model_validate(payload)
|
||||
|
||||
async def create_user(self, body: dict[str, Any]) -> RemnawaveUser:
|
||||
payload = await self._request_json("POST", "users", json=body)
|
||||
return RemnawaveUser.model_validate(payload)
|
||||
|
||||
async def update_user(self, body: dict[str, Any]) -> RemnawaveUser:
|
||||
payload = await self._request_json("PATCH", "users", json=body)
|
||||
return RemnawaveUser.model_validate(payload)
|
||||
|
||||
async def get_all_users(self, *, start: int = 0, size: int = 100) -> PaginatedUsers:
|
||||
payload = await self._request_json(
|
||||
"GET",
|
||||
"users",
|
||||
params={"start": start, "size": size},
|
||||
)
|
||||
return PaginatedUsers.model_validate(payload)
|
||||
|
||||
async def get_user_subscription_history(self, user_uuid: str) -> SubscriptionRequestHistory:
|
||||
payload = await self._request_json(
|
||||
"GET",
|
||||
f"users/{user_uuid}/subscription-request-history",
|
||||
)
|
||||
return SubscriptionRequestHistory.model_validate(payload)
|
||||
|
||||
async def resolve_user(self, identifier: str) -> ResolvedUser:
|
||||
body: dict[str, Any]
|
||||
|
||||
if UUID_RE.match(identifier):
|
||||
body = {"uuid": identifier}
|
||||
elif identifier.isdigit():
|
||||
body = {"id": int(identifier)}
|
||||
else:
|
||||
try:
|
||||
payload = await self._request_json(
|
||||
"POST",
|
||||
"users/resolve",
|
||||
json={"shortUuid": identifier},
|
||||
)
|
||||
return ResolvedUser.model_validate(payload)
|
||||
except RemnawaveApiError as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
body = {"username": identifier}
|
||||
|
||||
payload = await self._request_json("POST", "users/resolve", json=body)
|
||||
return ResolvedUser.model_validate(payload)
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
json: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
response = await self._client.request(
|
||||
method=method,
|
||||
url=path.lstrip("/"),
|
||||
params=params,
|
||||
json=json,
|
||||
)
|
||||
|
||||
if response.is_error:
|
||||
raise self._build_error(response)
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
|
||||
return self.unwrap_payload(payload)
|
||||
|
||||
def _build_error(self, response: httpx.Response) -> RemnawaveApiError:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
payload = None
|
||||
|
||||
message = "Unknown Remnawave API error"
|
||||
if isinstance(payload, Mapping):
|
||||
for key in ("message", "error", "code"):
|
||||
candidate = payload.get(key)
|
||||
if candidate:
|
||||
message = str(candidate)
|
||||
break
|
||||
|
||||
error_code = payload.get("errorCode")
|
||||
if error_code:
|
||||
message = f"{message} [{error_code}]"
|
||||
|
||||
error_items = payload.get("errors")
|
||||
if isinstance(error_items, list):
|
||||
details: list[str] = []
|
||||
for item in error_items:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
|
||||
path_value = item.get("path")
|
||||
if isinstance(path_value, list):
|
||||
path_text = ".".join(str(part) for part in path_value if part is not None)
|
||||
elif path_value is not None:
|
||||
path_text = str(path_value)
|
||||
else:
|
||||
path_text = ""
|
||||
|
||||
detail_message = str(item.get("message") or item.get("code") or "").strip()
|
||||
if not detail_message:
|
||||
continue
|
||||
|
||||
if path_text:
|
||||
details.append(f"{path_text}: {detail_message}")
|
||||
else:
|
||||
details.append(detail_message)
|
||||
|
||||
if details:
|
||||
message = f"{message} | {'; '.join(details)}"
|
||||
elif response.text:
|
||||
message = response.text
|
||||
|
||||
return RemnawaveApiError(
|
||||
status_code=response.status_code,
|
||||
message=message,
|
||||
payload=payload,
|
||||
)
|
||||
72
app/services/support_ticket_service.py
Normal file
72
app/services/support_ticket_service.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import SupportTicket
|
||||
|
||||
|
||||
class SupportTicketService:
|
||||
def __init__(self, *, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
async def create_ticket(
|
||||
self,
|
||||
*,
|
||||
public_id: str,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
display_name: str,
|
||||
user_message: str,
|
||||
support_chat_id: int,
|
||||
support_thread_id: int | None,
|
||||
support_message_id: int,
|
||||
) -> SupportTicket:
|
||||
async with self._session_factory() as session:
|
||||
ticket = SupportTicket(
|
||||
public_id=public_id,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
user_message=user_message,
|
||||
support_chat_id=support_chat_id,
|
||||
support_thread_id=support_thread_id,
|
||||
support_message_id=support_message_id,
|
||||
status="OPEN",
|
||||
)
|
||||
session.add(ticket)
|
||||
await session.commit()
|
||||
await session.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
async def get_ticket_by_support_message(
|
||||
self,
|
||||
*,
|
||||
support_chat_id: int,
|
||||
support_message_id: int,
|
||||
) -> SupportTicket | None:
|
||||
async with self._session_factory() as session:
|
||||
return await session.scalar(
|
||||
select(SupportTicket).where(
|
||||
SupportTicket.support_chat_id == support_chat_id,
|
||||
SupportTicket.support_message_id == support_message_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def mark_answered(self, *, ticket_id: int) -> None:
|
||||
async with self._session_factory() as session:
|
||||
ticket = await session.get(SupportTicket, ticket_id)
|
||||
if ticket is None:
|
||||
return
|
||||
|
||||
ticket.status = "ANSWERED"
|
||||
ticket.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def get_open_tickets_count(self) -> int:
|
||||
async with self._session_factory() as session:
|
||||
total = await session.scalar(
|
||||
select(func.count(SupportTicket.id)).where(SupportTicket.status == "OPEN")
|
||||
)
|
||||
return int(total or 0)
|
||||
811
app/services/sync_service.py
Normal file
811
app/services/sync_service.py
Normal file
@@ -0,0 +1,811 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import (
|
||||
ReferralCode,
|
||||
ReferralInvite,
|
||||
InternalSquad,
|
||||
RemnawaveUser,
|
||||
RemnawaveUserInternalSquad,
|
||||
SubscriptionRequestLog,
|
||||
TelegramUser,
|
||||
)
|
||||
from app.schemas.remnawave import RemnawaveUser as RemoteRemnawaveUser
|
||||
from app.schemas.remnawave import SubscriptionRequestHistory
|
||||
from app.services.remnawave_client import RemnawaveApiClient
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CachedUserView:
|
||||
record: RemnawaveUser
|
||||
internal_squads: list[str] = field(default_factory=list)
|
||||
recent_requests: list[SubscriptionRequestLog] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ReferralSummary:
|
||||
total_invited: int = 0
|
||||
referral_code: str = ""
|
||||
applied_referral_code: str = ""
|
||||
recent_names: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdminStats:
|
||||
total_telegram_users: int = 0
|
||||
total_cached_users: int = 0
|
||||
active_cached_users: int = 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdminTelegramUserListItem:
|
||||
telegram_id: int
|
||||
username: str | None
|
||||
first_name: str | None
|
||||
last_name: str | None
|
||||
language_code: str | None
|
||||
is_admin: bool
|
||||
is_blocked: bool
|
||||
last_seen_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
value = " ".join(part for part in [self.first_name, self.last_name] if part).strip()
|
||||
return value or self.username or str(self.telegram_id)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdminTelegramUsersPage:
|
||||
items: list[AdminTelegramUserListItem] = field(default_factory=list)
|
||||
page: int = 1
|
||||
page_size: int = 8
|
||||
total_items: int = 0
|
||||
total_pages: int = 1
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdminTelegramUserProfile:
|
||||
user: TelegramUser
|
||||
referral_code: str = ""
|
||||
applied_referral_code: str = ""
|
||||
invited_count: int = 0
|
||||
recent_invited_names: list[str] = field(default_factory=list)
|
||||
accesses: list[CachedUserView] = field(default_factory=list)
|
||||
|
||||
|
||||
class SyncService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Settings,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
remnawave_client: RemnawaveApiClient,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._session_factory = session_factory
|
||||
self._remnawave_client = remnawave_client
|
||||
|
||||
async def is_user_blocked(self, telegram_id: int) -> bool:
|
||||
async with self._session_factory() as session:
|
||||
is_blocked = await session.scalar(
|
||||
select(TelegramUser.is_blocked).where(
|
||||
TelegramUser.telegram_id == telegram_id
|
||||
)
|
||||
)
|
||||
return bool(is_blocked)
|
||||
|
||||
async def block_telegram_user(self, telegram_id: int) -> None:
|
||||
async with self._session_factory() as session:
|
||||
user = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
if user is not None:
|
||||
user.is_blocked = True
|
||||
user.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def unblock_telegram_user(self, telegram_id: int) -> None:
|
||||
async with self._session_factory() as session:
|
||||
user = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
if user is not None:
|
||||
user.is_blocked = False
|
||||
user.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def get_expiring_users(
|
||||
self,
|
||||
*,
|
||||
days_before: int,
|
||||
tolerance_hours: int = 6,
|
||||
) -> list[tuple[int, int, datetime]]:
|
||||
"""Return (remnawave_user.id, telegram_id, expire_at) for users expiring within window."""
|
||||
from datetime import timedelta
|
||||
|
||||
now = utcnow()
|
||||
window_start = now + timedelta(days=days_before) - timedelta(hours=tolerance_hours)
|
||||
window_end = now + timedelta(days=days_before) + timedelta(hours=tolerance_hours)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
rows = await session.execute(
|
||||
select(
|
||||
RemnawaveUser.id,
|
||||
RemnawaveUser.telegram_id,
|
||||
RemnawaveUser.expire_at,
|
||||
)
|
||||
.where(
|
||||
RemnawaveUser.status == "ACTIVE",
|
||||
RemnawaveUser.telegram_id.isnot(None),
|
||||
RemnawaveUser.expire_at >= window_start,
|
||||
RemnawaveUser.expire_at <= window_end,
|
||||
)
|
||||
)
|
||||
return [(row[0], row[1], row[2]) for row in rows.all()]
|
||||
|
||||
async def register_telegram_user(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> None:
|
||||
async with self._session_factory() as session:
|
||||
await self._upsert_telegram_user(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
|
||||
await session.commit()
|
||||
|
||||
async def apply_referral_code(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
referral_code: str,
|
||||
) -> str:
|
||||
async with self._session_factory() as session:
|
||||
invited = await self._upsert_telegram_user(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
|
||||
|
||||
existing = await session.scalar(
|
||||
select(ReferralInvite).where(
|
||||
ReferralInvite.invited_telegram_id == telegram_id
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
raise ValueError("Реферальный код уже применен к вашему аккаунту.")
|
||||
|
||||
inviter_telegram_id = await self._find_inviter_by_referral_code(session, referral_code)
|
||||
if inviter_telegram_id is None:
|
||||
raise ValueError("Реферальный код не найден.")
|
||||
if inviter_telegram_id == telegram_id:
|
||||
raise ValueError("Нельзя использовать собственный реферальный код.")
|
||||
|
||||
invite_created = await self._upsert_referral_invite(
|
||||
session,
|
||||
invited=invited,
|
||||
referred_by_telegram_id=inviter_telegram_id,
|
||||
)
|
||||
if not invite_created:
|
||||
raise ValueError("Не удалось применить реферальный код.")
|
||||
|
||||
await session.commit()
|
||||
return referral_code.strip().upper()
|
||||
|
||||
async def get_referral_summary(self, telegram_id: int) -> ReferralSummary:
|
||||
async with self._session_factory() as session:
|
||||
referral_code = await self._get_or_create_referral_code(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
await session.commit()
|
||||
total_invited = await session.scalar(
|
||||
select(func.count(ReferralInvite.id)).where(
|
||||
ReferralInvite.inviter_telegram_id == telegram_id
|
||||
)
|
||||
)
|
||||
recent_rows = await session.execute(
|
||||
select(ReferralInvite.invited_display_name)
|
||||
.where(ReferralInvite.inviter_telegram_id == telegram_id)
|
||||
.order_by(ReferralInvite.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_names = list(recent_rows.scalars().all())
|
||||
applied_referral_code = await session.scalar(
|
||||
select(ReferralCode.code)
|
||||
.join(
|
||||
ReferralInvite,
|
||||
ReferralInvite.inviter_telegram_id == ReferralCode.telegram_id,
|
||||
)
|
||||
.where(ReferralInvite.invited_telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
return ReferralSummary(
|
||||
total_invited=int(total_invited or 0),
|
||||
referral_code=referral_code,
|
||||
applied_referral_code=applied_referral_code or "",
|
||||
recent_names=recent_names,
|
||||
)
|
||||
|
||||
async def get_admin_stats(self) -> AdminStats:
|
||||
async with self._session_factory() as session:
|
||||
total_telegram_users = await session.scalar(
|
||||
select(func.count(TelegramUser.id))
|
||||
)
|
||||
total_cached_users = await session.scalar(
|
||||
select(func.count(RemnawaveUser.id))
|
||||
)
|
||||
active_cached_users = await session.scalar(
|
||||
select(func.count(RemnawaveUser.id)).where(RemnawaveUser.status == "ACTIVE")
|
||||
)
|
||||
|
||||
return AdminStats(
|
||||
total_telegram_users=int(total_telegram_users or 0),
|
||||
total_cached_users=int(total_cached_users or 0),
|
||||
active_cached_users=int(active_cached_users or 0),
|
||||
)
|
||||
|
||||
async def get_admin_telegram_users_page(
|
||||
self,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int = 8,
|
||||
) -> AdminTelegramUsersPage:
|
||||
resolved_page_size = max(1, min(page_size, 20))
|
||||
|
||||
async with self._session_factory() as session:
|
||||
total_items = int(
|
||||
await session.scalar(select(func.count(TelegramUser.id))) or 0
|
||||
)
|
||||
total_pages = max(1, math.ceil(total_items / resolved_page_size)) if total_items else 1
|
||||
resolved_page = min(max(page, 1), total_pages)
|
||||
|
||||
rows = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(TelegramUser)
|
||||
.order_by(
|
||||
TelegramUser.last_seen_at.desc(),
|
||||
TelegramUser.created_at.desc(),
|
||||
TelegramUser.id.desc(),
|
||||
)
|
||||
.offset((resolved_page - 1) * resolved_page_size)
|
||||
.limit(resolved_page_size)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
return AdminTelegramUsersPage(
|
||||
items=[
|
||||
AdminTelegramUserListItem(
|
||||
telegram_id=row.telegram_id,
|
||||
username=row.username,
|
||||
first_name=row.first_name,
|
||||
last_name=row.last_name,
|
||||
language_code=row.language_code,
|
||||
is_admin=row.is_admin,
|
||||
is_blocked=row.is_blocked,
|
||||
last_seen_at=row.last_seen_at,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
for row in rows
|
||||
],
|
||||
page=resolved_page,
|
||||
page_size=resolved_page_size,
|
||||
total_items=total_items,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
async def get_admin_telegram_user_profile(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
) -> AdminTelegramUserProfile | None:
|
||||
async with self._session_factory() as session:
|
||||
user = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
referral_code = await session.scalar(
|
||||
select(ReferralCode.code).where(ReferralCode.telegram_id == telegram_id)
|
||||
)
|
||||
invited_count = await session.scalar(
|
||||
select(func.count(ReferralInvite.id)).where(
|
||||
ReferralInvite.inviter_telegram_id == telegram_id
|
||||
)
|
||||
)
|
||||
recent_rows = await session.execute(
|
||||
select(ReferralInvite.invited_display_name)
|
||||
.where(ReferralInvite.inviter_telegram_id == telegram_id)
|
||||
.order_by(ReferralInvite.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_invited_names = list(recent_rows.scalars().all())
|
||||
applied_referral_code = await session.scalar(
|
||||
select(ReferralCode.code)
|
||||
.join(
|
||||
ReferralInvite,
|
||||
ReferralInvite.inviter_telegram_id == ReferralCode.telegram_id,
|
||||
)
|
||||
.where(ReferralInvite.invited_telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
accesses = await self.get_cached_users_for_telegram(telegram_id)
|
||||
|
||||
return AdminTelegramUserProfile(
|
||||
user=user,
|
||||
referral_code=referral_code or "",
|
||||
applied_referral_code=applied_referral_code or "",
|
||||
invited_count=int(invited_count or 0),
|
||||
recent_invited_names=recent_invited_names,
|
||||
accesses=accesses,
|
||||
)
|
||||
|
||||
async def sync_users_for_telegram(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> list[CachedUserView]:
|
||||
remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
owner = await self._upsert_telegram_user(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
await self._sync_remote_users_for_telegram(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
remote_users=remote_users,
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return await self.get_cached_users_for_telegram(telegram_id)
|
||||
|
||||
async def refresh_cached_users_for_telegram(self, *, telegram_id: int) -> list[CachedUserView]:
|
||||
remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
owner = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
await self._sync_remote_users_for_telegram(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
remote_users=remote_users,
|
||||
owner=owner,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return await self.get_cached_users_for_telegram(telegram_id)
|
||||
|
||||
async def link_user_by_short_uuid(
|
||||
self,
|
||||
*,
|
||||
short_uuid: str,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> CachedUserView:
|
||||
remote_user = await self._remnawave_client.get_user_by_short_uuid(short_uuid)
|
||||
|
||||
if remote_user.telegram_id and remote_user.telegram_id != telegram_id:
|
||||
raise ValueError("Этот аккаунт уже привязан к другому Telegram ID.")
|
||||
|
||||
updated_user = await self._remnawave_client.update_user(
|
||||
{
|
||||
"uuid": str(remote_user.uuid),
|
||||
"telegramId": telegram_id,
|
||||
}
|
||||
)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
owner = await self._upsert_telegram_user(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
await self._upsert_remnawave_user(session, updated_user, owner)
|
||||
if self._settings.sync_subscription_history:
|
||||
history = await self._remnawave_client.get_user_subscription_history(
|
||||
str(updated_user.uuid)
|
||||
)
|
||||
await self._upsert_history(session, str(updated_user.uuid), history)
|
||||
await session.commit()
|
||||
|
||||
user = await self.get_cached_user_by_uuid(str(updated_user.uuid), include_history=True)
|
||||
if user is None:
|
||||
raise ValueError("Не удалось получить локальный кэш пользователя после привязки.")
|
||||
return user
|
||||
|
||||
async def sync_user_by_identifier(
|
||||
self,
|
||||
*,
|
||||
identifier: str,
|
||||
include_history: bool = True,
|
||||
) -> CachedUserView:
|
||||
resolved = await self._remnawave_client.resolve_user(identifier)
|
||||
remote_user = await self._remnawave_client.get_user_by_uuid(str(resolved.uuid))
|
||||
history = None
|
||||
|
||||
if include_history and self._settings.sync_subscription_history:
|
||||
history = await self._remnawave_client.get_user_subscription_history(str(resolved.uuid))
|
||||
|
||||
async with self._session_factory() as session:
|
||||
await self._upsert_remnawave_user(session, remote_user, owner=None)
|
||||
if history is not None:
|
||||
await self._upsert_history(session, str(resolved.uuid), history)
|
||||
await session.commit()
|
||||
|
||||
user = await self.get_cached_user_by_uuid(str(resolved.uuid), include_history=include_history)
|
||||
if user is None:
|
||||
raise ValueError("Не удалось получить локальный кэш пользователя после синхронизации.")
|
||||
return user
|
||||
|
||||
async def sync_all_users(self, batch_size: int) -> int:
|
||||
start = 0
|
||||
total_synced = 0
|
||||
|
||||
while True:
|
||||
page = await self._remnawave_client.get_all_users(start=start, size=batch_size)
|
||||
if not page.users:
|
||||
break
|
||||
|
||||
async with self._session_factory() as session:
|
||||
for remote_user in page.users:
|
||||
await self._upsert_remnawave_user(session, remote_user, owner=None)
|
||||
total_synced += 1
|
||||
await session.commit()
|
||||
|
||||
start += len(page.users)
|
||||
if start >= page.total:
|
||||
break
|
||||
|
||||
return total_synced
|
||||
|
||||
async def get_cached_users_for_telegram(self, telegram_id: int) -> list[CachedUserView]:
|
||||
async with self._session_factory() as session:
|
||||
rows = await session.execute(
|
||||
select(RemnawaveUser, InternalSquad.name)
|
||||
.outerjoin(
|
||||
RemnawaveUserInternalSquad,
|
||||
RemnawaveUserInternalSquad.user_id == RemnawaveUser.id,
|
||||
)
|
||||
.outerjoin(
|
||||
InternalSquad,
|
||||
InternalSquad.uuid == RemnawaveUserInternalSquad.squad_uuid,
|
||||
)
|
||||
.where(RemnawaveUser.telegram_id == telegram_id)
|
||||
.order_by(RemnawaveUser.username.asc())
|
||||
)
|
||||
return self._build_views(rows.all())
|
||||
|
||||
async def get_cached_user_by_uuid(
|
||||
self,
|
||||
user_uuid: str,
|
||||
*,
|
||||
include_history: bool = False,
|
||||
) -> CachedUserView | None:
|
||||
async with self._session_factory() as session:
|
||||
rows = await session.execute(
|
||||
select(RemnawaveUser, InternalSquad.name)
|
||||
.outerjoin(
|
||||
RemnawaveUserInternalSquad,
|
||||
RemnawaveUserInternalSquad.user_id == RemnawaveUser.id,
|
||||
)
|
||||
.outerjoin(
|
||||
InternalSquad,
|
||||
InternalSquad.uuid == RemnawaveUserInternalSquad.squad_uuid,
|
||||
)
|
||||
.where(RemnawaveUser.rw_uuid == user_uuid)
|
||||
)
|
||||
views = self._build_views(rows.all())
|
||||
if not views:
|
||||
return None
|
||||
|
||||
view = views[0]
|
||||
if include_history:
|
||||
history_rows = await session.execute(
|
||||
select(SubscriptionRequestLog)
|
||||
.where(SubscriptionRequestLog.user_uuid == user_uuid)
|
||||
.order_by(SubscriptionRequestLog.request_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
view.recent_requests = list(history_rows.scalars().all())
|
||||
return view
|
||||
|
||||
async def _upsert_telegram_user(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> TelegramUser:
|
||||
record = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
if record is None:
|
||||
record = TelegramUser(telegram_id=telegram_id)
|
||||
session.add(record)
|
||||
|
||||
record.username = username
|
||||
record.first_name = first_name
|
||||
record.last_name = last_name
|
||||
record.language_code = language_code
|
||||
record.is_admin = self._settings.is_admin(telegram_id)
|
||||
record.last_seen_at = utcnow()
|
||||
record.updated_at = utcnow()
|
||||
|
||||
await session.flush()
|
||||
return record
|
||||
|
||||
async def _sync_remote_users_for_telegram(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
telegram_id: int,
|
||||
remote_users: list[RemoteRemnawaveUser],
|
||||
owner: TelegramUser | None,
|
||||
) -> None:
|
||||
active_uuids = {str(remote_user.uuid) for remote_user in remote_users}
|
||||
for remote_user in remote_users:
|
||||
await self._upsert_remnawave_user(session, remote_user, owner)
|
||||
|
||||
filters = [RemnawaveUser.telegram_id == telegram_id]
|
||||
if owner is not None:
|
||||
filters.append(RemnawaveUser.owner_telegram_user_id == owner.id)
|
||||
|
||||
stale_records = await session.scalars(
|
||||
select(RemnawaveUser).where(or_(*filters))
|
||||
)
|
||||
for record in stale_records:
|
||||
if record.rw_uuid in active_uuids:
|
||||
continue
|
||||
|
||||
if record.telegram_id == telegram_id:
|
||||
record.telegram_id = None
|
||||
if owner is not None and record.owner_telegram_user_id == owner.id:
|
||||
record.owner_telegram_user_id = None
|
||||
record.synced_at = utcnow()
|
||||
|
||||
async def _upsert_remnawave_user(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
remote_user: RemoteRemnawaveUser,
|
||||
owner: TelegramUser | None,
|
||||
) -> RemnawaveUser:
|
||||
record = await session.scalar(
|
||||
select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(remote_user.uuid))
|
||||
)
|
||||
|
||||
if record is None:
|
||||
record = RemnawaveUser(rw_uuid=str(remote_user.uuid))
|
||||
session.add(record)
|
||||
|
||||
owner_id = owner.id if owner is not None else await self._find_owner_id(session, remote_user.telegram_id)
|
||||
|
||||
record.owner_telegram_user_id = owner_id
|
||||
record.rw_id = remote_user.id
|
||||
record.short_uuid = remote_user.short_uuid
|
||||
record.username = remote_user.username
|
||||
record.status = remote_user.status
|
||||
record.traffic_limit_bytes = remote_user.traffic_limit_bytes
|
||||
record.used_traffic_bytes = remote_user.user_traffic.used_traffic_bytes
|
||||
record.lifetime_used_traffic_bytes = remote_user.user_traffic.lifetime_used_traffic_bytes
|
||||
record.expire_at = self._normalize_datetime(remote_user.expire_at)
|
||||
record.telegram_id = remote_user.telegram_id
|
||||
record.email = remote_user.email
|
||||
record.description = remote_user.description
|
||||
record.tag = remote_user.tag
|
||||
record.hwid_device_limit = remote_user.hwid_device_limit
|
||||
record.external_squad_uuid = self._maybe_str(remote_user.external_squad_uuid)
|
||||
record.trojan_password = remote_user.trojan_password
|
||||
record.vless_uuid = str(remote_user.vless_uuid)
|
||||
record.ss_password = remote_user.ss_password
|
||||
record.last_triggered_threshold = remote_user.last_triggered_threshold
|
||||
record.sub_revoked_at = self._normalize_datetime(remote_user.sub_revoked_at)
|
||||
record.sub_last_user_agent = remote_user.sub_last_user_agent
|
||||
record.sub_last_opened_at = self._normalize_datetime(remote_user.sub_last_opened_at)
|
||||
record.last_traffic_reset_at = self._normalize_datetime(remote_user.last_traffic_reset_at)
|
||||
record.subscription_url = remote_user.subscription_url
|
||||
record.online_at = self._normalize_datetime(remote_user.user_traffic.online_at)
|
||||
record.first_connected_at = self._normalize_datetime(remote_user.user_traffic.first_connected_at)
|
||||
record.last_connected_node_uuid = self._maybe_str(remote_user.user_traffic.last_connected_node_uuid)
|
||||
record.remote_created_at = self._normalize_datetime(remote_user.created_at)
|
||||
record.remote_updated_at = self._normalize_datetime(remote_user.updated_at)
|
||||
record.synced_at = utcnow()
|
||||
|
||||
await session.flush()
|
||||
await session.execute(
|
||||
delete(RemnawaveUserInternalSquad).where(
|
||||
RemnawaveUserInternalSquad.user_id == record.id
|
||||
)
|
||||
)
|
||||
|
||||
for squad in remote_user.active_internal_squads:
|
||||
squad_uuid = str(squad.uuid)
|
||||
squad_record = await session.get(InternalSquad, squad_uuid)
|
||||
if squad_record is None:
|
||||
squad_record = InternalSquad(uuid=squad_uuid, name=squad.name, synced_at=utcnow())
|
||||
session.add(squad_record)
|
||||
else:
|
||||
squad_record.name = squad.name
|
||||
squad_record.synced_at = utcnow()
|
||||
|
||||
session.add(
|
||||
RemnawaveUserInternalSquad(
|
||||
user_id=record.id,
|
||||
squad_uuid=squad_uuid,
|
||||
)
|
||||
)
|
||||
|
||||
await session.flush()
|
||||
return record
|
||||
|
||||
async def _upsert_history(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_uuid: str,
|
||||
history: SubscriptionRequestHistory,
|
||||
) -> None:
|
||||
for item in history.records:
|
||||
existing = await session.scalar(
|
||||
select(SubscriptionRequestLog).where(
|
||||
SubscriptionRequestLog.remote_id == item.id
|
||||
)
|
||||
)
|
||||
if existing is None:
|
||||
existing = SubscriptionRequestLog(remote_id=item.id, user_uuid=user_uuid)
|
||||
session.add(existing)
|
||||
|
||||
existing.user_uuid = user_uuid
|
||||
existing.request_at = self._normalize_datetime(item.request_at)
|
||||
existing.request_ip = item.request_ip
|
||||
existing.user_agent = item.user_agent
|
||||
existing.synced_at = utcnow()
|
||||
|
||||
async def _upsert_referral_invite(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
invited: TelegramUser,
|
||||
referred_by_telegram_id: int | None,
|
||||
) -> bool:
|
||||
if referred_by_telegram_id is None or referred_by_telegram_id == invited.telegram_id:
|
||||
return False
|
||||
|
||||
existing = await session.scalar(
|
||||
select(ReferralInvite).where(
|
||||
ReferralInvite.invited_telegram_id == invited.telegram_id
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
return False
|
||||
|
||||
display_name = " ".join(
|
||||
part for part in [invited.first_name, invited.last_name] if part
|
||||
).strip() or invited.username or str(invited.telegram_id)
|
||||
|
||||
session.add(
|
||||
ReferralInvite(
|
||||
inviter_telegram_id=referred_by_telegram_id,
|
||||
invited_telegram_id=invited.telegram_id,
|
||||
invited_username=invited.username,
|
||||
invited_display_name=display_name,
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
async def _get_or_create_referral_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
telegram_id: int,
|
||||
) -> str:
|
||||
existing = await session.scalar(
|
||||
select(ReferralCode).where(ReferralCode.telegram_id == telegram_id)
|
||||
)
|
||||
if existing is not None:
|
||||
return existing.code
|
||||
|
||||
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
while True:
|
||||
code = "".join(secrets.choice(alphabet) for _ in range(8))
|
||||
duplicate = await session.scalar(
|
||||
select(ReferralCode.id).where(ReferralCode.code == code)
|
||||
)
|
||||
if duplicate is None:
|
||||
session.add(ReferralCode(telegram_id=telegram_id, code=code))
|
||||
await session.flush()
|
||||
return code
|
||||
|
||||
async def _find_inviter_by_referral_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
referred_by_referral_code: str | None,
|
||||
) -> int | None:
|
||||
raw_code = (referred_by_referral_code or "").strip().upper()
|
||||
if not raw_code:
|
||||
return None
|
||||
|
||||
return await session.scalar(
|
||||
select(ReferralCode.telegram_id).where(ReferralCode.code == raw_code)
|
||||
)
|
||||
|
||||
async def _find_owner_id(self, session: AsyncSession, telegram_id: int | None) -> int | None:
|
||||
if telegram_id is None:
|
||||
return None
|
||||
|
||||
return await session.scalar(
|
||||
select(TelegramUser.id).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_datetime(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value
|
||||
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
@staticmethod
|
||||
def _maybe_str(value: object | None) -> str | None:
|
||||
return str(value) if value is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _build_views(rows: list[tuple[RemnawaveUser, str | None]]) -> list[CachedUserView]:
|
||||
view_map: dict[int, CachedUserView] = {}
|
||||
for user, squad_name in rows:
|
||||
view = view_map.setdefault(user.id, CachedUserView(record=user))
|
||||
if squad_name and squad_name not in view.internal_squads:
|
||||
view.internal_squads.append(squad_name)
|
||||
return list(view_map.values())
|
||||
1
app/utils/__init__.py
Normal file
1
app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Utility helpers."""
|
||||
182
app/utils/formatters.py
Normal file
182
app/utils/formatters.py
Normal file
@@ -0,0 +1,182 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user