This commit is contained in:
2026-04-22 18:34:13 +03:00
parent 3992121397
commit ce7a1f70b2
60 changed files with 10243 additions and 3443 deletions

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Application package."""

1
app/bot/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Bot package."""

View File

@@ -0,0 +1 @@
"""Bot handlers package."""

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
View File

@@ -0,0 +1 @@
"""Telegram bot UI helpers."""

595
app/bot/ui/panel.py Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
"""Database package."""

11
app/db/base.py Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
"""Pydantic schemas."""

75
app/schemas/remnawave.py Normal file
View 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
View File

@@ -0,0 +1 @@
"""Services package."""

View 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)

View 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)

View 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]

View 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,
)

View 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)

View 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
View File

@@ -0,0 +1 @@
"""Utility helpers."""

182
app/utils/formatters.py Normal file
View 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)