v2.0
This commit is contained in:
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.env
|
||||
.env.local
|
||||
.venv
|
||||
.analysis-venv
|
||||
.verify-venv
|
||||
.ticket-verify-venv
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
.git
|
||||
.gitignore
|
||||
tests
|
||||
203
.env.example
203
.env.example
@@ -1,25 +1,188 @@
|
||||
# Telegram Bot
|
||||
BOT_TOKEN=YOUR_BOT_TOKEN_HERE
|
||||
# =========================
|
||||
# Telegram bot
|
||||
# =========================
|
||||
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_USER=root
|
||||
DB_PASSWORD=
|
||||
DB_NAME=botyobshik
|
||||
# Токен бота от BotFather
|
||||
BOT_TOKEN=1234567890:REPLACE_WITH_BOT_TOKEN
|
||||
|
||||
# Support
|
||||
SUPPORT_USERNAME=@support_username
|
||||
# Telegram ID администраторов через запятую
|
||||
# Пример: 123456789,987654321
|
||||
BOT_ADMIN_IDS=123456789
|
||||
|
||||
# Payment
|
||||
PAYMENT_CHANNEL_ID=-1003591520479
|
||||
PAYMENT_THREAD_ID=5910
|
||||
PAYMENT_TICKET_CHANNEL_ID=-1003591520479
|
||||
PAYMENT_TICKET_THREAD_ID=788
|
||||
PAYMENT_PHONE=+7 904 788 77 35
|
||||
# Telegram ID модераторов через запятую
|
||||
# Модераторы могут отвечать на тикеты и подтверждать оплаты
|
||||
BOT_MODERATOR_IDS=
|
||||
|
||||
# Admin
|
||||
ADMIN_IDS=123456789,987654321
|
||||
# Прокси для доступа к Telegram Bot API, если хост не видит api.telegram.org напрямую
|
||||
# Подходят обычные URL: http://... или socks5://...
|
||||
TELEGRAM_PROXY_URL=
|
||||
|
||||
# Remnawave API
|
||||
REMWAVE_API_URL=https://panel.remnawave.com/api
|
||||
REMWAVE_API_KEY=your_api_key_here
|
||||
# Название бренда в пользовательской панели
|
||||
BOT_BRAND_NAME=OREOL VPN
|
||||
|
||||
# Username бота без @, нужен для реферальных ссылок
|
||||
BOT_PUBLIC_USERNAME=your_bot_username
|
||||
|
||||
# Опциональная ссылка на поддержку
|
||||
BOT_SUPPORT_URL=https://t.me/your_support_account
|
||||
|
||||
# Опциональная внешняя ссылка на Terms of Use
|
||||
BOT_TERMS_URL=
|
||||
|
||||
# Ссылка на приватный чат/топик для тикетов поддержки
|
||||
# Формат: https://t.me/c/<chat>/<topic>/<message>
|
||||
BOT_SUPPORT_TICKET_LINK=https://t.me/c/1234567890/2/3
|
||||
|
||||
# Альтернативный способ указать chat id для тикетов вручную
|
||||
# Если заполнен, имеет приоритет над BOT_SUPPORT_TICKET_LINK
|
||||
BOT_SUPPORT_TICKET_CHAT_ID=
|
||||
|
||||
# Альтернативный способ указать thread/topic id для тикетов вручную
|
||||
# Если > 0, имеет приоритет над BOT_SUPPORT_TICKET_LINK
|
||||
BOT_SUPPORT_TICKET_THREAD_ID=0
|
||||
|
||||
# Включить стартовую картинку в панели
|
||||
BOT_START_IMAGE_ENABLED=true
|
||||
|
||||
# Путь до стартовой картинки
|
||||
# Можно абсолютный путь или относительный от корня проекта
|
||||
BOT_START_IMAGE_PATH=assets/main.png
|
||||
|
||||
|
||||
# =========================
|
||||
# Remnawave
|
||||
# =========================
|
||||
|
||||
# Базовый URL Remnawave без /api или с /api — клиент нормализует сам
|
||||
REMNAWAVE_BASE_URL=https://panel.example.com
|
||||
|
||||
# API token Remnawave
|
||||
REMNAWAVE_API_TOKEN=replace-with-remnawave-api-token
|
||||
|
||||
# Дополнительный X-Api-Key, если API закрыт через Caddy
|
||||
REMNAWAVE_CADDY_API_KEY=
|
||||
|
||||
# HTTP timeout в секундах для запросов к Remnawave
|
||||
REMNAWAVE_TIMEOUT_SECONDS=20
|
||||
|
||||
|
||||
# =========================
|
||||
# MariaDB
|
||||
# =========================
|
||||
|
||||
# Хост MariaDB
|
||||
DB_HOST=127.0.0.1
|
||||
|
||||
# Порт MariaDB
|
||||
DB_PORT=3306
|
||||
|
||||
# Имя базы данных
|
||||
DB_NAME=telegabot
|
||||
|
||||
# Пользователь MariaDB
|
||||
DB_USER=telegabot
|
||||
|
||||
# Пароль MariaDB
|
||||
DB_PASSWORD=change_me
|
||||
|
||||
# Root-пароль MariaDB для docker-compose
|
||||
# Нужен контейнеру db при первом запуске
|
||||
DB_ROOT_PASSWORD=change_root_me
|
||||
|
||||
# Логировать SQLAlchemy SQL-запросы в консоль
|
||||
# true = логировать, false = не логировать
|
||||
DB_ECHO=false
|
||||
|
||||
# Попытаться создать базу при старте, если её ещё нет
|
||||
CREATE_DATABASE_ON_START=true
|
||||
|
||||
# Создавать таблицы ORM при старте приложения
|
||||
CREATE_TABLES_ON_START=true
|
||||
|
||||
# Сохранять историю subscription request log из Remnawave
|
||||
SYNC_SUBSCRIPTION_HISTORY=true
|
||||
|
||||
# Размер батча при /sync_all
|
||||
SYNC_BATCH_SIZE=100
|
||||
|
||||
# Уровень логов Python
|
||||
# Примеры: INFO, DEBUG, WARNING, ERROR
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
|
||||
# =========================
|
||||
# Manual payment flow
|
||||
# =========================
|
||||
|
||||
# Набор тарифов в формате дни:цена_в_рублях
|
||||
# Пример: 30:250,180:600,365:1000
|
||||
PAYMENT_PLANS=30:250,180:600,365:1000
|
||||
|
||||
# Реквизиты или инструкция для перевода
|
||||
# Этот текст бот показывает пользователю после выбора тарифа
|
||||
PAYMENT_TRANSFER_TEXT=Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN
|
||||
|
||||
# Ссылка на приватный чат/топик, куда бот отправляет чеки на модерацию
|
||||
# Формат: https://t.me/c/<chat>/<topic>/<message>
|
||||
PAYMENT_REVIEW_LINK=https://t.me/c/1234567890/56/57
|
||||
|
||||
# Альтернативный способ указать chat id review-чата вручную
|
||||
# Если заполнен, имеет приоритет над PAYMENT_REVIEW_LINK
|
||||
PAYMENT_REVIEW_CHAT_ID=
|
||||
|
||||
# Альтернативный способ указать thread/topic id review-чата вручную
|
||||
# Если > 0, имеет приоритет над PAYMENT_REVIEW_LINK
|
||||
PAYMENT_REVIEW_THREAD_ID=0
|
||||
|
||||
# Процент скидки по реферальному коду
|
||||
REFERRAL_DISCOUNT_PERCENT=5
|
||||
|
||||
# Бонус рефереру за подтверждённую оплату приглашённого пользователя
|
||||
# Начисляется в днях и продлевает доступ
|
||||
REFERRAL_BONUS_DAYS=7
|
||||
|
||||
|
||||
# =========================
|
||||
# Payment / Remnawave provisioning
|
||||
# =========================
|
||||
|
||||
# Резервный код тарифа по умолчанию
|
||||
# Сейчас в основном используется PAYMENT_PLANS, но поле оставлено для совместимости
|
||||
PAYMENT_PLAN_CODE=vpn_30d
|
||||
|
||||
# Резервное название базового тарифа
|
||||
PAYMENT_PLAN_TITLE=OREOL VPN на 30 дней
|
||||
|
||||
# Резервное описание базового тарифа
|
||||
PAYMENT_PLAN_DESCRIPTION=Доступ к VPN на 30 дней
|
||||
|
||||
# Резервная цена базового тарифа
|
||||
PAYMENT_PLAN_PRICE_STARS=250
|
||||
|
||||
# Резервная длительность базового тарифа
|
||||
PAYMENT_PLAN_DURATION_DAYS=30
|
||||
|
||||
# Лимит трафика в GB
|
||||
# 0 = без лимита
|
||||
PAYMENT_PLAN_TRAFFIC_LIMIT_GB=0
|
||||
|
||||
# Стратегия сброса трафика для Remnawave
|
||||
# Обычно NO_RESET
|
||||
PAYMENT_PLAN_TRAFFIC_RESET_PERIOD=NO_RESET
|
||||
|
||||
# UUID внутренних групп Remnawave, которые назначаются новому или продлеваемому доступу
|
||||
# Несколько значений через запятую
|
||||
PAYMENT_INTERNAL_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111
|
||||
|
||||
# UUID внешней группы Remnawave, если используется
|
||||
PAYMENT_EXTERNAL_SQUAD_UUID=
|
||||
|
||||
# Текст для пользователя, если оплата отклонена или доступ не выдался
|
||||
PAYMENT_SUPPORT_TEXT=Если оплата прошла, но доступ не выдался, напишите в поддержку.
|
||||
|
||||
# Префикс логина Remnawave
|
||||
# Итоговый логин будет вида Oreol-<telegram_id>-<username>
|
||||
PAYMENT_USERNAME_PREFIX=Oreol
|
||||
|
||||
# Tag для пользователей, создаваемых ботом в Remnawave
|
||||
PAYMENT_USER_TAG=BOT
|
||||
|
||||
54
.gitignore
vendored
54
.gitignore
vendored
@@ -1,42 +1,16 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.venv/
|
||||
.analysis-venv/
|
||||
.verify-venv/
|
||||
.ticket-verify-venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(github.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
848
README.md
848
README.md
@@ -1,115 +1,785 @@
|
||||
# OreolRP Subscription Bot
|
||||
# OREOL VPN Bot
|
||||
|
||||
Telegram-бот для продажи подписок на **aiogram 3.x** с поддержкой нескольких языков.
|
||||
Telegram-бот на `aiogram 3` с интеграцией в `Remnawave API`, локальным кэшем в `MariaDB`, пользовательской панелью, тикетами поддержки, реферальной системой и ручной оплатой с подтверждением админом.
|
||||
|
||||
Этот README написан как рабочий handoff-документ: по нему можно поднять проект, понять текущую архитектуру и продолжить разработку в новой сессии без восстановления контекста по кускам.
|
||||
|
||||
## Что умеет бот
|
||||
|
||||
- Показывает стартовую панель пользователя через `/start`
|
||||
- Подтягивает аккаунты Remnawave по `telegramId`
|
||||
- Хранит локальный кэш пользователей и истории подписок в MariaDB
|
||||
- Поддерживает привязку существующего аккаунта Remnawave по `short_uuid`
|
||||
- Показывает профиль, статус подписки, срок и трафик
|
||||
- Работает с реферальными кодами и скидкой перед оплатой
|
||||
- Поддерживает ручную оплату переводом с отправкой чека в бот
|
||||
- Отправляет чек в отдельный Telegram-канал/топик на проверку
|
||||
- Даёт админу или модератору подтвердить или отклонить оплату
|
||||
- После подтверждения создаёт или продлевает доступ в Remnawave
|
||||
- Поддерживает тикеты в отдельный канал и ответы только от админа/модератора
|
||||
- Показывает админам отдельную Telegram-панель с метриками и быстрыми действиями
|
||||
|
||||
## Текущий сценарий оплаты
|
||||
|
||||
Сейчас в проекте не используется Telegram Stars. Оплата работает вручную:
|
||||
|
||||
1. Пользователь нажимает `Купить подписку`
|
||||
2. Бот показывает список тарифов из `PAYMENT_PLANS`
|
||||
3. Если у пользователя применён реферальный код, скидка учитывается до выбора тарифа
|
||||
4. После выбора тарифа бот показывает реквизиты из `PAYMENT_TRANSFER_TEXT`
|
||||
5. Пользователь отправляет чек следующим сообщением в бота
|
||||
6. Бот пересылает чек в review-чат оплаты
|
||||
7. Админ или модератор нажимает `Подтвердить` или `Отклонить`
|
||||
8. При подтверждении бот создаёт или продлевает доступ в Remnawave и отправляет пользователю подписку
|
||||
|
||||
### Тарифы
|
||||
|
||||
Тарифы задаются одной переменной:
|
||||
|
||||
```env
|
||||
PAYMENT_PLANS=30:250,180:600,365:1000
|
||||
```
|
||||
|
||||
Формат:
|
||||
|
||||
- `дни:цена_в_рублях`
|
||||
- элементы разделяются запятой
|
||||
|
||||
Пример выше означает:
|
||||
|
||||
- 30 дней = 250 ₽
|
||||
- 180 дней = 600 ₽
|
||||
- 365 дней = 1000 ₽
|
||||
|
||||
### Логин в Remnawave
|
||||
|
||||
Логин создаётся в формате:
|
||||
|
||||
```text
|
||||
Oreol-<telegram_id>-<username>
|
||||
```
|
||||
|
||||
Пример:
|
||||
|
||||
```text
|
||||
Oreol-591220249-slkes
|
||||
```
|
||||
|
||||
Если username отсутствует, используется `first_name`, а если и его нет, то `user`.
|
||||
|
||||
Важно:
|
||||
|
||||
- логин обрезается до 36 символов, чтобы соответствовать ограничениям Remnawave
|
||||
- повторная покупка у того же пользователя не должна создавать новый логин, а должна продлевать уже существующий доступ
|
||||
|
||||
## Важное замечание по базе
|
||||
|
||||
Если таблица `payment_orders` уже была создана старой версией проекта, в ней мог остаться `UNIQUE` на `provision_username`.
|
||||
|
||||
Для текущей логики это неверно, потому что один и тот же логин может использоваться в нескольких заказах одного пользователя при продлении.
|
||||
|
||||
Правильное состояние:
|
||||
|
||||
- в ORM `provision_username` обычный индекс без `UNIQUE`
|
||||
- в `sql/schema.sql` тоже без `UNIQUE`
|
||||
|
||||
Если таблица уже существует в MariaDB, может потребоваться вручную убрать старое уникальное ограничение.
|
||||
|
||||
## Архитектура
|
||||
|
||||
### Основные слои
|
||||
|
||||
- `app/main.py`
|
||||
- инициализация приложения
|
||||
- подключение к БД
|
||||
- создание клиента Remnawave
|
||||
- создание `SyncService`, `SupportTicketService`, `PaymentService`
|
||||
- запуск aiogram
|
||||
|
||||
- `app/config.py`
|
||||
- все настройки из `.env`
|
||||
- парсинг ID админов и модераторов
|
||||
- парсинг ссылок Telegram вида `https://t.me/c/.../.../...`
|
||||
- парсинг тарифов оплаты
|
||||
|
||||
- `app/services/remnawave_client.py`
|
||||
- прямой HTTP-клиент к Remnawave API через `httpx`
|
||||
- `GET /users/by-telegram-id/{telegramId}`
|
||||
- `GET /users/{uuid}`
|
||||
- `GET /users/by-short-uuid/{shortUuid}`
|
||||
- `GET /users/by-username/{username}`
|
||||
- `POST /users`
|
||||
- `PATCH /users`
|
||||
- `POST /users/resolve`
|
||||
- `GET /users`
|
||||
- `GET /users/{uuid}/subscription-request-history`
|
||||
|
||||
- `app/services/sync_service.py`
|
||||
- регистрация Telegram-пользователей
|
||||
- синхронизация пользователей из Remnawave в MariaDB
|
||||
- привязка аккаунтов по `short_uuid`
|
||||
- реферальные коды и приглашения
|
||||
|
||||
- `app/services/payment_service.py`
|
||||
- список доступных тарифов
|
||||
- создание ручного заказа
|
||||
- расчёт скидки по реферальному коду
|
||||
- хранение статусов заказа
|
||||
- создание нового доступа в Remnawave
|
||||
- продление существующего доступа в Remnawave
|
||||
|
||||
- `app/services/support_ticket_service.py`
|
||||
- создание тикетов
|
||||
- поиск тикета по support message ID
|
||||
- отметка, что тикет обработан
|
||||
|
||||
- `app/bot/handlers/main.py`
|
||||
- все команды и callback-и
|
||||
- стартовая панель
|
||||
- меню оплаты
|
||||
- FSM для тикетов, рефкодов и чека оплаты
|
||||
- приём ответа модератора на тикет
|
||||
- приём кнопок `Подтвердить / Отклонить` в review-канале оплаты
|
||||
|
||||
- `app/bot/ui/panel.py`
|
||||
- рендер панели пользователя
|
||||
- инлайн-кнопки разделов
|
||||
- тексты профиля, подписки, рефералки, правил и поддержки
|
||||
|
||||
- `app/utils/formatters.py`
|
||||
- форматирование дат
|
||||
- форматирование трафика
|
||||
- форматирование карточек доступа
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
botyobshik/
|
||||
├── main.py # Точка входа
|
||||
├── requirements.txt # Зависимости
|
||||
├── .env # Конфигурация (не в git)
|
||||
├── .env.example # Пример конфигурации
|
||||
├── README.md # Документация
|
||||
├── config/
|
||||
│ └── __init__.py # Загрузка настроек из .env
|
||||
├── core/
|
||||
│ └── __init__.py # Работа с БД (авто-создание)
|
||||
├── handlers/
|
||||
│ └── __init__.py # Обработчики команд
|
||||
├── keyboards/
|
||||
│ └── __init__.py # Клавиатуры
|
||||
└── locales/
|
||||
├── __init__.py # Локализация
|
||||
├── ru.json # Русский
|
||||
├── en.json # English
|
||||
└── kz.json # Қазақша
|
||||
```text
|
||||
telegabot/
|
||||
├── app/
|
||||
│ ├── bot/
|
||||
│ │ ├── handlers/
|
||||
│ │ │ └── main.py
|
||||
│ │ └── ui/
|
||||
│ │ └── panel.py
|
||||
│ ├── db/
|
||||
│ │ ├── base.py
|
||||
│ │ ├── models.py
|
||||
│ │ └── session.py
|
||||
│ ├── schemas/
|
||||
│ │ └── remnawave.py
|
||||
│ ├── services/
|
||||
│ │ ├── payment_service.py
|
||||
│ │ ├── remnawave_client.py
|
||||
│ │ ├── support_ticket_service.py
|
||||
│ │ └── sync_service.py
|
||||
│ ├── utils/
|
||||
│ │ └── formatters.py
|
||||
│ ├── config.py
|
||||
│ └── main.py
|
||||
├── assets/
|
||||
│ └── main.png
|
||||
├── sql/
|
||||
│ └── schema.sql
|
||||
├── tests/
|
||||
│ ├── test_config.py
|
||||
│ ├── test_formatters.py
|
||||
│ ├── test_panel_ui.py
|
||||
│ └── test_remnawave_client.py
|
||||
├── .env.example
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── main.py
|
||||
├── pyproject.toml
|
||||
├── README.md
|
||||
└── run.bat
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
## Команды бота
|
||||
|
||||
### Пользовательские
|
||||
|
||||
- `/start` — открыть стартовую панель
|
||||
|
||||
### Админские
|
||||
|
||||
- `Админ-панель` в UI — сводка по боту, поиск пользователя, полная синхронизация, быстрые переходы в рабочие чаты
|
||||
- `/lookup <uuid|id|username|short_uuid>` — найти и синхронизировать пользователя
|
||||
- `/sync_all` — массовая синхронизация пользователей Remnawave в MariaDB
|
||||
|
||||
## База данных
|
||||
|
||||
### Основные таблицы
|
||||
|
||||
- `telegram_users`
|
||||
- Telegram-пользователи, взаимодействовавшие с ботом
|
||||
|
||||
- `remnawave_users`
|
||||
- локальный кэш пользователей Remnawave
|
||||
|
||||
- `internal_squads`
|
||||
- справочник внутренних групп
|
||||
|
||||
- `remnawave_user_internal_squads`
|
||||
- связь many-to-many пользователей и групп
|
||||
|
||||
- `subscription_request_logs`
|
||||
- история запросов подписки
|
||||
|
||||
- `support_tickets`
|
||||
- тикеты поддержки
|
||||
|
||||
- `referral_codes`
|
||||
- персональные коды пользователей
|
||||
|
||||
- `referral_invites`
|
||||
- кто кого пригласил
|
||||
|
||||
- `referral_bonuses`
|
||||
- начисленные и ожидающие применения бонусы реферерам
|
||||
|
||||
- `payment_orders`
|
||||
- ручные платежи, статусы и выданные доступы
|
||||
|
||||
### Статусы заказов оплаты
|
||||
|
||||
Сейчас используются:
|
||||
|
||||
- `PENDING` — заказ создан, чек ещё не отправлен
|
||||
- `REVIEW` — чек отправлен на проверку
|
||||
- `REJECTED` — платёж отклонён модератором
|
||||
- `FULFILLED` — доступ выдан или продлён
|
||||
|
||||
## Реферальная система
|
||||
|
||||
Что реализовано:
|
||||
|
||||
- каждому пользователю создаётся персональный код
|
||||
- можно сгенерировать ссылку вида `https://t.me/<bot_username>?start=ref_<CODE>`
|
||||
- сам переход по ссылке скидку не активирует
|
||||
- чтобы получить скидку, пользователь должен вручную ввести код до оплаты
|
||||
- размер скидки задаётся в `REFERRAL_DISCOUNT_PERCENT`
|
||||
- за каждую подтверждённую оплату по рефкоду реферер получает бонус в днях
|
||||
- размер бонуса задаётся в `REFERRAL_BONUS_DAYS`
|
||||
|
||||
## Тикеты поддержки
|
||||
|
||||
Сценарий:
|
||||
|
||||
1. Пользователь открывает раздел `Поддержка`
|
||||
2. Нажимает `Создать тикет`
|
||||
3. Отправляет следующим сообщением вопрос или описание проблемы
|
||||
4. Бот отправляет тикет в support-чат
|
||||
5. Админ или модератор отвечает реплаем на сообщение с тикетом
|
||||
6. Бот доставляет ответ пользователю в личный чат
|
||||
|
||||
Доступ к ответу на тикет есть только у:
|
||||
|
||||
- пользователей из `BOT_ADMIN_IDS`
|
||||
- пользователей из `BOT_MODERATOR_IDS`
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
Полный шаблон лежит в `.env.example`.
|
||||
|
||||
Критический минимум для запуска:
|
||||
|
||||
- `BOT_TOKEN`
|
||||
- `REMNAWAVE_BASE_URL`
|
||||
- `REMNAWAVE_API_TOKEN`
|
||||
- `DB_HOST`
|
||||
- `DB_PORT`
|
||||
- `DB_NAME`
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
- `PAYMENT_INTERNAL_SQUAD_UUIDS`
|
||||
|
||||
Для полного сценария поддержки и оплаты также нужны:
|
||||
|
||||
- `BOT_PUBLIC_USERNAME`
|
||||
- `BOT_SUPPORT_TICKET_LINK` или `BOT_SUPPORT_TICKET_CHAT_ID`
|
||||
- `PAYMENT_TRANSFER_TEXT`
|
||||
- `PAYMENT_REVIEW_LINK` или `PAYMENT_REVIEW_CHAT_ID`
|
||||
|
||||
## Как настроить `.env`
|
||||
|
||||
1. Скопировать шаблон:
|
||||
|
||||
```bat
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
2. Заполнить обязательные значения
|
||||
|
||||
3. Проверить:
|
||||
|
||||
- корректен ли `BOT_TOKEN`
|
||||
- доступен ли `REMNAWAVE_BASE_URL`
|
||||
- валиден ли `REMNAWAVE_API_TOKEN`
|
||||
- существует ли MariaDB и есть ли права на создание БД, если `CREATE_DATABASE_ON_START=true`
|
||||
- указаны ли реальные UUID групп в `PAYMENT_INTERNAL_SQUAD_UUIDS`
|
||||
- указаны ли реальные реквизиты в `PAYMENT_TRANSFER_TEXT`
|
||||
|
||||
## Пример важных настроек
|
||||
|
||||
```env
|
||||
BOT_BRAND_NAME=OREOL VPN
|
||||
BOT_PUBLIC_USERNAME=oreol_vpn_bot
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_NAME=oreolvpn
|
||||
DB_USER=oreolvpn
|
||||
DB_PASSWORD=strong_password
|
||||
|
||||
PAYMENT_PLANS=30:250,180:600,365:1000
|
||||
PAYMENT_TRANSFER_TEXT=Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN
|
||||
PAYMENT_REVIEW_LINK=https://t.me/c/3646494169/56/57
|
||||
PAYMENT_INTERNAL_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111
|
||||
REFERRAL_DISCOUNT_PERCENT=5
|
||||
PAYMENT_USERNAME_PREFIX=Oreol
|
||||
```
|
||||
|
||||
## Поддержка Telegram-ссылок `t.me/c/...`
|
||||
|
||||
Проект умеет разбирать приватные ссылки Telegram вида:
|
||||
|
||||
```text
|
||||
https://t.me/c/3646494169/56/57
|
||||
```
|
||||
|
||||
Разбор происходит так:
|
||||
|
||||
- chat id = `-1003646494169`
|
||||
- thread id = `56`
|
||||
- последний сегмент `57` — это message id, для маршрутизации топика он не нужен
|
||||
|
||||
Это используется для:
|
||||
|
||||
- `BOT_SUPPORT_TICKET_LINK`
|
||||
- `PAYMENT_REVIEW_LINK`
|
||||
|
||||
## Как запускать
|
||||
|
||||
### Вариант 1. Windows через `run.bat`
|
||||
|
||||
```bat
|
||||
run.bat
|
||||
```
|
||||
|
||||
Что делает батник:
|
||||
|
||||
- переходит в папку проекта
|
||||
- проверяет `.env`
|
||||
- создаёт `.venv`, если его нет
|
||||
- ставит зависимости
|
||||
- запускает `main.py`
|
||||
|
||||
### Вариант 2. Ручной локальный запуск
|
||||
|
||||
```bat
|
||||
py -3 -m venv .venv
|
||||
.venv\Scripts\python -m pip install -e .[dev]
|
||||
.venv\Scripts\python main.py
|
||||
```
|
||||
|
||||
### Вариант 3. Docker
|
||||
|
||||
1. **Установите зависимости:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
2. **Создайте `.env` файл:**
|
||||
Это полный запуск вместе с MariaDB. База данных уже есть в проекте: сервис `db` описан в `docker-compose.yml`, данные сохраняются в volume `mariadb_data`.
|
||||
|
||||
#### Что нужно установить заранее
|
||||
|
||||
- Docker Desktop на Windows/macOS
|
||||
- или Docker Engine + Docker Compose plugin на Linux
|
||||
|
||||
Проверьте, что команды доступны:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
#### Ubuntu Server 24.04: установка Docker Engine
|
||||
|
||||
Для `Ubuntu Server 24.04` рекомендуется ставить Docker Engine из официального Docker-репозитория.
|
||||
|
||||
1. Обновить пакеты и поставить базовые утилиты:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y ca-certificates curl git
|
||||
```
|
||||
|
||||
2. Добавить официальный GPG-ключ Docker:
|
||||
|
||||
```bash
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
```
|
||||
|
||||
3. Подключить Docker repository:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
|
||||
Types: deb
|
||||
URIs: https://download.docker.com/linux/ubuntu
|
||||
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
|
||||
Components: stable
|
||||
Architectures: $(dpkg --print-architecture)
|
||||
Signed-By: /etc/apt/keyrings/docker.asc
|
||||
EOF
|
||||
```
|
||||
|
||||
4. Установить Docker Engine и Compose plugin:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
```
|
||||
|
||||
5. Включить Docker после перезагрузки:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable docker --now
|
||||
```
|
||||
|
||||
6. Разрешить запуск Docker без `sudo`:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
После этого выйдите из SSH-сессии и зайдите снова, либо выполните:
|
||||
|
||||
```bash
|
||||
newgrp docker
|
||||
```
|
||||
|
||||
7. Проверить установку:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker compose version
|
||||
docker ps
|
||||
```
|
||||
|
||||
Если проект будет стоять на VPS или выделенном сервере, этого достаточно: отдельный Docker Desktop на Linux Server не нужен.
|
||||
|
||||
#### Что нужно подготовить перед первым запуском
|
||||
|
||||
1. Скопировать пример конфига:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. **Настройте `.env`:**
|
||||
```ini
|
||||
BOT_TOKEN=1234567890:AAH...
|
||||
DB_HOST=localhost
|
||||
DB_USER=root
|
||||
DB_PASSWORD=ваш_пароль
|
||||
DB_NAME=botyobshik
|
||||
SUPPORT_USERNAME=@support_username
|
||||
На Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
```
|
||||
|
||||
4. **Запустите бота:**
|
||||
2. Открыть `.env` и заполнить обязательные значения:
|
||||
|
||||
- `BOT_TOKEN`
|
||||
- `BOT_ADMIN_IDS`
|
||||
- `REMNAWAVE_BASE_URL`
|
||||
- `REMNAWAVE_API_TOKEN`
|
||||
- `DB_NAME`
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
- `DB_ROOT_PASSWORD`
|
||||
- `PAYMENT_INTERNAL_SQUAD_UUIDS`
|
||||
|
||||
Обычно ещё сразу настраивают:
|
||||
|
||||
- `BOT_PUBLIC_USERNAME`
|
||||
- `BOT_SUPPORT_URL`
|
||||
- `BOT_SUPPORT_TICKET_LINK` или `BOT_SUPPORT_TICKET_CHAT_ID`
|
||||
- `PAYMENT_TRANSFER_TEXT`
|
||||
- `PAYMENT_REVIEW_LINK` или `PAYMENT_REVIEW_CHAT_ID`
|
||||
|
||||
#### Важный момент по базе данных в Docker
|
||||
|
||||
При запуске через `docker compose` контейнер бота сам получает:
|
||||
|
||||
```env
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
```
|
||||
|
||||
Это уже задано в `docker-compose.yml`. В `.env` для Docker главное задать:
|
||||
|
||||
- `DB_NAME`
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
- `DB_ROOT_PASSWORD`
|
||||
|
||||
Если вы запускаете проект только в Docker, `DB_HOST` и `DB_PORT` можно не менять вручную.
|
||||
|
||||
MariaDB в текущем `docker-compose.yml` не публикуется наружу на хост, чтобы база не торчала во внешний интернет по `3306`. Бот подключается к ней по внутренней Docker-сети через hostname `db`.
|
||||
|
||||
#### Первый запуск
|
||||
|
||||
Запустите сборку и контейнеры:
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
🎉 База данных и таблицы создадутся **автоматически**!
|
||||
Будут подняты два сервиса:
|
||||
|
||||
## Возможности
|
||||
- `db` — MariaDB 11.7
|
||||
- `bot` — приложение на Python
|
||||
|
||||
- ✅ Авто-создание БД и таблиц
|
||||
- ✅ Команда `/start` с меню
|
||||
- ✅ Проверка статуса подписки
|
||||
- ✅ Покупка подписки (тарифы)
|
||||
- ✅ Техподдержка
|
||||
- ✅ Правила сервиса
|
||||
- ✅ Смена языка (RU/EN/KZ)
|
||||
- ✅ Асинхронная работа с БД
|
||||
- ✅ Конфигурация через `.env`
|
||||
Полный сценарий для Ubuntu Server обычно выглядит так:
|
||||
|
||||
## Редактирование
|
||||
|
||||
### Переменные окружения (`.env`)
|
||||
| Переменная | Описание |
|
||||
|------------|----------|
|
||||
| `BOT_TOKEN` | Токен от @BotFather |
|
||||
| `DB_HOST` | Хост MySQL |
|
||||
| `DB_USER` | Пользователь MySQL |
|
||||
| `DB_PASSWORD` | Пароль MySQL |
|
||||
| `DB_NAME` | Имя базы данных |
|
||||
| `SUPPORT_USERNAME` | Контакт поддержки |
|
||||
|
||||
### Тексты и кнопки
|
||||
Все тексты редактируются в `locales/*.json`:
|
||||
|
||||
| Ключ | Описание |
|
||||
|------|----------|
|
||||
| `welcome` | Приветствие |
|
||||
| `subscription_active/inactive` | Статусы подписки |
|
||||
| `buttons.*` | Текст кнопок (buy, support, rules, language, back) |
|
||||
| `messages.rules_title` | Заголовок правил |
|
||||
| `messages.rules_text` | Текст правил (многострочный) |
|
||||
| `messages.support` | Сообщение поддержки |
|
||||
| `messages.select_tariff` | Выбор тарифа |
|
||||
| `tariffs.*` | Названия тарифов |
|
||||
| `languages.*` | Названия языков |
|
||||
|
||||
### Пример редактирования правил
|
||||
|
||||
Откройте `locales/ru.json` и измените:
|
||||
|
||||
```json
|
||||
"messages": {
|
||||
"rules_title": "📜 Правила сервиса",
|
||||
"rules_text": "1. Первое правило\n2. Второе правило\n3. Третье правило"
|
||||
}
|
||||
```bash
|
||||
git clone <URL_репозитория> telegabot
|
||||
cd telegabot
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Используйте `\n` для переноса строки.
|
||||
#### Как проверить, что всё поднялось
|
||||
|
||||
## База данных
|
||||
Посмотреть список контейнеров:
|
||||
|
||||
При запуске бот автоматически создаёт:
|
||||
- Базу данных `botyobshik`
|
||||
- Таблицу `subscriptions` (подписки)
|
||||
- Таблицу `user_languages` (языки пользователей)
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Посмотреть логи бота:
|
||||
|
||||
```bash
|
||||
docker compose logs -f bot
|
||||
```
|
||||
|
||||
Посмотреть логи базы:
|
||||
|
||||
```bash
|
||||
docker compose logs -f db
|
||||
```
|
||||
|
||||
Проверить, что бот видит базу и база отвечает:
|
||||
|
||||
```bash
|
||||
docker compose exec db mariadb -u"$DB_USER" -p"$DB_PASSWORD" -e "SHOW DATABASES;"
|
||||
```
|
||||
|
||||
Если нужно зайти в MariaDB вручную:
|
||||
|
||||
```bash
|
||||
docker compose exec db mariadb -u"$DB_USER" -p"$DB_PASSWORD" "$DB_NAME"
|
||||
```
|
||||
|
||||
Если всё настроено правильно, сценарий такой:
|
||||
|
||||
1. поднимается `db`
|
||||
2. healthcheck MariaDB становится `healthy`
|
||||
3. стартует `bot`
|
||||
4. бот при старте создаёт БД и таблицы, если включены:
|
||||
- `CREATE_DATABASE_ON_START=true`
|
||||
- `CREATE_TABLES_ON_START=true`
|
||||
|
||||
#### Остановка и повторный запуск
|
||||
|
||||
Остановить контейнеры:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Запустить снова без пересборки:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Пересобрать после изменения кода:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
#### Как удалить всё вместе с базой
|
||||
|
||||
Если нужен полный сброс, включая MariaDB volume:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
После этого база будет создана заново при следующем запуске.
|
||||
|
||||
#### Обновление на Ubuntu Server
|
||||
|
||||
Если вы обновили код проекта:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Если меняли только `.env`, обычно достаточно:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### Если бот не стартует
|
||||
|
||||
Проверьте по пунктам:
|
||||
|
||||
- заполнен ли `BOT_TOKEN`
|
||||
- доступен ли `REMNAWAVE_BASE_URL`
|
||||
- корректен ли `REMNAWAVE_API_TOKEN`
|
||||
- заполнен ли `PAYMENT_INTERNAL_SQUAD_UUIDS`
|
||||
- не пустые ли `DB_PASSWORD` и `DB_ROOT_PASSWORD`
|
||||
- есть ли у сервиса `db` статус `healthy` в `docker compose ps`
|
||||
|
||||
Если хотите открыть MariaDB наружу для внешнего клиента, это нужно делать осознанно: добавьте `ports` обратно в сервис `db` и отдельно ограничьте доступ через firewall или private network.
|
||||
|
||||
#### Минимальный сценарий запуска в Docker
|
||||
|
||||
1. Скопировать `.env.example` в `.env`
|
||||
2. Заполнить Telegram, Remnawave и DB-переменные
|
||||
3. Выполнить `docker compose up -d --build`
|
||||
4. Проверить `docker compose logs -f bot`
|
||||
|
||||
## Проверка проекта
|
||||
|
||||
### Синтаксис
|
||||
|
||||
```bat
|
||||
py -3 -m compileall app main.py tests
|
||||
```
|
||||
|
||||
### Тесты
|
||||
|
||||
```bat
|
||||
.venv\Scripts\python -m pytest
|
||||
```
|
||||
|
||||
Если основная `.venv` невалидна из-за переезда проекта между машинами, пересоздай её.
|
||||
|
||||
## Что уже протестировано
|
||||
|
||||
Покрыто тестами:
|
||||
|
||||
- парсинг конфигурации
|
||||
- разбор support/review ссылок Telegram
|
||||
- парсинг тарифов
|
||||
- форматтеры текста
|
||||
- UI панели
|
||||
- базовые методы `RemnawaveApiClient`
|
||||
|
||||
Последняя локальная проверка в этой сетевой копии:
|
||||
|
||||
- `py -3 -m compileall app main.py tests` — успешно
|
||||
- `pytest` — `26 passed`
|
||||
|
||||
## Известные нюансы
|
||||
|
||||
### 1. Перенос `.venv` между машинами
|
||||
|
||||
Если скопировать проект вместе с уже готовой `.venv` в другую папку, на другой компьютер или на сетевой диск, окружение может ссылаться на старый путь Python.
|
||||
|
||||
Типичная ошибка:
|
||||
|
||||
```text
|
||||
did not find executable at ...python.exe
|
||||
```
|
||||
|
||||
Решение:
|
||||
|
||||
1. удалить `.venv`
|
||||
2. создать её заново
|
||||
3. снова установить зависимости
|
||||
|
||||
### 2. Ошибка `Unknown database`
|
||||
|
||||
Если MariaDB доступна, но самой базы из `DB_NAME` ещё нет, можно увидеть:
|
||||
|
||||
```text
|
||||
OperationalError: (1049, "Unknown database '...'" )
|
||||
```
|
||||
|
||||
Что делать:
|
||||
|
||||
- оставить `CREATE_DATABASE_ON_START=true`, если у пользователя есть права на создание БД
|
||||
- либо создать БД вручную
|
||||
|
||||
### 3. Прокси Telegram
|
||||
|
||||
Для `TELEGRAM_PROXY_URL` подходят обычные proxy URL:
|
||||
|
||||
- `http://user:pass@host:port`
|
||||
- `socks5://host:port`
|
||||
|
||||
Не подходят клиентские ссылки вроде:
|
||||
|
||||
- `vless://...`
|
||||
- `vmess://...`
|
||||
- `trojan://...`
|
||||
- `tg://proxy?...` для aiogram напрямую
|
||||
|
||||
### 4. Review-чат оплаты должен быть настроен
|
||||
|
||||
Если `PAYMENT_REVIEW_LINK` и `PAYMENT_REVIEW_CHAT_ID` пустые, пользователь сможет выбрать тариф, но бот не сможет отправить чек на модерацию.
|
||||
|
||||
### 5. Internal squads обязательны для выдачи доступа
|
||||
|
||||
Если `PAYMENT_INTERNAL_SQUAD_UUIDS` пустой, бот не сможет корректно создать доступ в Remnawave.
|
||||
|
||||
## Безопасность
|
||||
|
||||
Нельзя публиковать:
|
||||
|
||||
- реальный `.env`
|
||||
- `BOT_TOKEN`
|
||||
- `REMNAWAVE_API_TOKEN`
|
||||
- `REMNAWAVE_CADDY_API_KEY`
|
||||
- банковские реквизиты из рабочего `PAYMENT_TRANSFER_TEXT`
|
||||
|
||||
Передавать между сессиями безопасно:
|
||||
|
||||
- код проекта
|
||||
- `README.md`
|
||||
- `.env.example`
|
||||
|
||||
## Что логично делать дальше
|
||||
|
||||
Следующие разумные шаги по проекту:
|
||||
|
||||
- добавить миграции через Alembic
|
||||
- хранить отдельный audit log по модерации оплат
|
||||
- сделать уведомление модераторам о новых чеках
|
||||
- добавить историю заказов пользователя в панели
|
||||
- сделать админ-панель со списком платежей
|
||||
- добавить продление конкретного существующего аккаунта по выбору, если у пользователя их несколько
|
||||
- добавить отдельный статус `APPROVED_BUT_NOT_DELIVERED`, если бот не смог отправить результат пользователю
|
||||
- покрыть тестами `PaymentService` и FSM оплаты
|
||||
|
||||
## Быстрый handoff
|
||||
|
||||
Если проект открывает новая сессия, порядок такой:
|
||||
|
||||
1. Прочитать этот README
|
||||
2. Проверить `.env`
|
||||
3. Проверить, рабочая ли `.venv`
|
||||
4. Убедиться, что доступны MariaDB и Remnawave
|
||||
5. Запустить `run.bat` или `python main.py`
|
||||
6. Проверить руками:
|
||||
- `/start`
|
||||
- открытие раздела подписки через кнопку в панели
|
||||
- ввод реферального кода
|
||||
- отправку чека
|
||||
- подтверждение оплаты модератором
|
||||
- выдачу ссылки пользователю
|
||||
- поддержку через тикет
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# OreolRP Subscription Bot
|
||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Application package."""
|
||||
1
app/bot/__init__.py
Normal file
1
app/bot/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Bot package."""
|
||||
1
app/bot/handlers/__init__.py
Normal file
1
app/bot/handlers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Bot handlers package."""
|
||||
2499
app/bot/handlers/main.py
Normal file
2499
app/bot/handlers/main.py
Normal file
File diff suppressed because it is too large
Load Diff
1
app/bot/ui/__init__.py
Normal file
1
app/bot/ui/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Telegram bot UI helpers."""
|
||||
595
app/bot/ui/panel.py
Normal file
595
app/bot/ui/panel.py
Normal file
@@ -0,0 +1,595 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from app.services.sync_service import CachedUserView
|
||||
from app.utils.formatters import format_bytes, format_datetime, format_payment_plan, format_traffic_limit
|
||||
|
||||
PANEL_SECTION_HOME = "home"
|
||||
PANEL_SECTION_ADMIN = "admin"
|
||||
PANEL_SECTION_SUBSCRIPTION = "subscription"
|
||||
PANEL_SECTION_REFERRAL = "referral"
|
||||
PANEL_SECTION_SUPPORT = "support"
|
||||
PANEL_SECTION_SUPPORT_TICKET = "support_ticket"
|
||||
PANEL_SECTION_TERMS = "terms"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PanelContext:
|
||||
telegram_id: int
|
||||
display_name: str
|
||||
username: str | None
|
||||
language_code: str | None
|
||||
is_admin: bool
|
||||
users: list[CachedUserView]
|
||||
referral_enabled: bool = True
|
||||
referral_code: str = ""
|
||||
referral_count: int = 0
|
||||
applied_referral_code: str = ""
|
||||
referral_link: str = ""
|
||||
referral_share_url: str = ""
|
||||
referral_recent_names: list[str] | None = None
|
||||
referral_discount_percent: int = 0
|
||||
referral_bonus_days: int = 0
|
||||
admin_total_telegram_users: int = 0
|
||||
admin_total_cached_users: int = 0
|
||||
admin_active_cached_users: int = 0
|
||||
admin_pending_orders: int = 0
|
||||
admin_review_orders: int = 0
|
||||
admin_open_tickets: int = 0
|
||||
admin_pending_referral_bonuses: int = 0
|
||||
payment_enabled: bool = False
|
||||
payment_plan_title: str = ""
|
||||
payment_plan_description: str = ""
|
||||
payment_plan_price_stars: int = 0
|
||||
payment_plan_duration_days: int = 0
|
||||
payment_plan_traffic_limit_bytes: int = 0
|
||||
api_error: str | None = None
|
||||
|
||||
|
||||
def _callback(section: str, *, refresh: bool = False) -> str:
|
||||
return f"panel:{section}:refresh" if refresh else f"panel:{section}"
|
||||
|
||||
|
||||
def _ticket_callback(action: str) -> str:
|
||||
return f"ticket:{action}"
|
||||
|
||||
|
||||
def _referral_callback(action: str) -> str:
|
||||
return f"referral:{action}"
|
||||
|
||||
|
||||
def _payment_callback(action: str) -> str:
|
||||
return f"payment:{action}"
|
||||
|
||||
|
||||
def _admin_callback(action: str) -> str:
|
||||
return f"admin:{action}"
|
||||
|
||||
|
||||
def _status_badge(status: str) -> str:
|
||||
mapping = {
|
||||
"ACTIVE": "🟢 Активна",
|
||||
"LIMITED": "🟡 Ограничена",
|
||||
"DISABLED": "⛔ Отключена",
|
||||
"EXPIRED": "🔴 Истекла",
|
||||
}
|
||||
return mapping.get(status.upper(), f"⚪ {html.escape(status)}")
|
||||
|
||||
|
||||
def _active_users_count(users: list[CachedUserView]) -> int:
|
||||
return sum(1 for user in users if user.record.status.upper() == "ACTIVE")
|
||||
|
||||
def _primary_user(users: list[CachedUserView]) -> CachedUserView | None:
|
||||
for user in users:
|
||||
if user.record.status.upper() == "ACTIVE":
|
||||
return user
|
||||
return users[0] if users else None
|
||||
|
||||
|
||||
def _home_subscription_status(users: list[CachedUserView]) -> str:
|
||||
return "Активна" if _active_users_count(users) > 0 else "Не активна"
|
||||
|
||||
|
||||
def _days_left_text(user: CachedUserView | None) -> str:
|
||||
if user is None or user.record.expire_at is None:
|
||||
return "—"
|
||||
|
||||
seconds_left = (user.record.expire_at - datetime.now()).total_seconds()
|
||||
if seconds_left <= 0:
|
||||
return "0 дней"
|
||||
|
||||
days_left = math.ceil(seconds_left / 86400)
|
||||
return f"{days_left} дней"
|
||||
|
||||
|
||||
def _traffic_summary_text(user: CachedUserView | None) -> str:
|
||||
if user is None:
|
||||
return "—"
|
||||
|
||||
return (
|
||||
f"{format_bytes(user.record.used_traffic_bytes)} / "
|
||||
f"{format_traffic_limit(user.record.traffic_limit_bytes)}"
|
||||
)
|
||||
|
||||
|
||||
def build_panel_keyboard(
|
||||
*,
|
||||
section: str,
|
||||
context: PanelContext,
|
||||
support_url: str = "",
|
||||
terms_url: str = "",
|
||||
payment_review_url: str = "",
|
||||
support_ticket_url: str = "",
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
primary_user = _primary_user(context.users)
|
||||
|
||||
if section == PANEL_SECTION_HOME:
|
||||
has_sub_link = bool(
|
||||
primary_user
|
||||
and primary_user.record.subscription_url.startswith(("http://", "https://"))
|
||||
)
|
||||
if has_sub_link:
|
||||
builder.button(
|
||||
text="🔗 Открыть подписку",
|
||||
url=primary_user.record.subscription_url,
|
||||
)
|
||||
builder.button(
|
||||
text="💳 Подписка",
|
||||
callback_data=_payment_callback("menu"),
|
||||
)
|
||||
if context.is_admin:
|
||||
builder.button(
|
||||
text="🛠 Админ-панель",
|
||||
callback_data=_callback(PANEL_SECTION_ADMIN),
|
||||
)
|
||||
if context.referral_enabled:
|
||||
builder.button(
|
||||
text="👥 Реферальная система",
|
||||
callback_data=_callback(PANEL_SECTION_REFERRAL),
|
||||
)
|
||||
builder.button(
|
||||
text="🆘 Поддержка",
|
||||
callback_data=_callback(PANEL_SECTION_SUPPORT),
|
||||
)
|
||||
if terms_url:
|
||||
builder.button(text="📜 Условия использования", url=terms_url)
|
||||
else:
|
||||
builder.button(
|
||||
text="📜 Условия использования",
|
||||
callback_data=_callback(PANEL_SECTION_TERMS),
|
||||
)
|
||||
if has_sub_link:
|
||||
if context.is_admin and context.referral_enabled:
|
||||
builder.adjust(1, 1, 1, 2, 1)
|
||||
elif context.is_admin:
|
||||
builder.adjust(1, 1, 1, 1, 1)
|
||||
else:
|
||||
if context.referral_enabled:
|
||||
builder.adjust(1, 1, 2, 1)
|
||||
else:
|
||||
builder.adjust(1, 1, 1, 1)
|
||||
else:
|
||||
if context.is_admin and context.referral_enabled:
|
||||
builder.adjust(1, 1, 2, 1)
|
||||
elif context.is_admin:
|
||||
builder.adjust(1, 1, 1, 1)
|
||||
else:
|
||||
if context.referral_enabled:
|
||||
builder.adjust(1, 2, 1)
|
||||
else:
|
||||
builder.adjust(1, 1, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
if section == PANEL_SECTION_ADMIN:
|
||||
if not context.is_admin:
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
builder.button(
|
||||
text="🔍 Найти пользователя",
|
||||
callback_data=_admin_callback("lookup"),
|
||||
)
|
||||
builder.button(
|
||||
text="👤 Пользователи",
|
||||
callback_data=_admin_callback("users"),
|
||||
)
|
||||
builder.button(
|
||||
text="🔄 Полная синхронизация",
|
||||
callback_data=_admin_callback("sync_all"),
|
||||
)
|
||||
builder.button(
|
||||
text="⚙️ Настройки",
|
||||
callback_data=_admin_callback("settings"),
|
||||
)
|
||||
if payment_review_url.strip():
|
||||
builder.button(text="💸 Очередь оплат", url=payment_review_url.strip())
|
||||
if support_ticket_url.strip():
|
||||
builder.button(text="🎫 Тикеты", url=support_ticket_url.strip())
|
||||
builder.button(
|
||||
text="↻ Обновить",
|
||||
callback_data=_callback(PANEL_SECTION_ADMIN, refresh=True),
|
||||
)
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(2, 2, 2, 2)
|
||||
return builder.as_markup()
|
||||
|
||||
if section == PANEL_SECTION_SUBSCRIPTION:
|
||||
if context.payment_enabled:
|
||||
builder.button(
|
||||
text="💳 Продлить подписку" if context.users else "💳 Купить подписку",
|
||||
callback_data=_payment_callback("buy"),
|
||||
)
|
||||
if primary_user and primary_user.record.subscription_url.startswith(("http://", "https://")):
|
||||
builder.button(
|
||||
text="🔗 Открыть подписку",
|
||||
url=primary_user.record.subscription_url,
|
||||
)
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
if section == PANEL_SECTION_REFERRAL:
|
||||
if not context.referral_enabled:
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
if context.referral_share_url:
|
||||
builder.button(
|
||||
text="📨 Поделиться ссылкой",
|
||||
url=context.referral_share_url,
|
||||
)
|
||||
builder.button(
|
||||
text="🎟 Ввести реферальный код",
|
||||
callback_data=_referral_callback("apply"),
|
||||
)
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
if section == PANEL_SECTION_SUPPORT:
|
||||
if support_url.strip():
|
||||
builder.button(text="Написать в поддержку", url=support_url.strip())
|
||||
builder.button(
|
||||
text="🎫 Создать тикет",
|
||||
callback_data=_ticket_callback("create"),
|
||||
)
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
if section == PANEL_SECTION_SUPPORT_TICKET:
|
||||
builder.button(
|
||||
text="🎫 Создать тикет",
|
||||
callback_data=_ticket_callback("create"),
|
||||
)
|
||||
builder.button(text="🆘 Поддержка", callback_data=_callback(PANEL_SECTION_SUPPORT))
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(1, 2)
|
||||
return builder.as_markup()
|
||||
|
||||
if section == PANEL_SECTION_TERMS:
|
||||
if terms_url:
|
||||
builder.button(text="📜 Открыть Terms of Use", url=terms_url)
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def render_panel_caption(*, section: str, context: PanelContext, brand_name: str) -> str:
|
||||
if section == PANEL_SECTION_ADMIN:
|
||||
return _render_admin_caption(context=context, brand_name=brand_name)
|
||||
if section == PANEL_SECTION_SUBSCRIPTION:
|
||||
return _render_subscription_caption(context=context)
|
||||
if section == PANEL_SECTION_REFERRAL:
|
||||
return _render_referral_caption(context=context)
|
||||
if section == PANEL_SECTION_SUPPORT:
|
||||
return _render_support_caption()
|
||||
if section == PANEL_SECTION_SUPPORT_TICKET:
|
||||
return _render_support_ticket_caption()
|
||||
if section == PANEL_SECTION_TERMS:
|
||||
return _render_terms_caption()
|
||||
return _render_home_caption(context=context, brand_name=brand_name)
|
||||
|
||||
|
||||
def _render_home_caption(*, context: PanelContext, brand_name: str) -> str:
|
||||
primary_user = _primary_user(context.users)
|
||||
|
||||
sub_lines = [
|
||||
f"Статус: <b>{_home_subscription_status(context.users)}</b>",
|
||||
f"Срок: <b>{_days_left_text(primary_user)}</b>",
|
||||
f"Трафик: <b>{_traffic_summary_text(primary_user)}</b>",
|
||||
]
|
||||
if primary_user and primary_user.record.subscription_url:
|
||||
sub_lines.append(f"Ссылка: <code>{html.escape(primary_user.record.subscription_url)}</code>")
|
||||
|
||||
lines = [
|
||||
f"Добро пожаловать в <b>{html.escape(brand_name)}</b>",
|
||||
"",
|
||||
"<b>Подписка</b>",
|
||||
"<blockquote>" + "\n".join(sub_lines) + "</blockquote>",
|
||||
]
|
||||
|
||||
if context.api_error:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<i>Remnawave временно недоступен. Показаны данные из локального кэша.</i>",
|
||||
]
|
||||
)
|
||||
|
||||
if context.is_admin:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<i>Для управления ботом используйте кнопку «Админ-панель» ниже.</i>",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_admin_caption(*, context: PanelContext, brand_name: str) -> str:
|
||||
if not context.is_admin:
|
||||
return "\n".join(
|
||||
[
|
||||
"<b>🛠 Админ-панель</b>",
|
||||
"",
|
||||
"<blockquote>Недостаточно прав для доступа к этому разделу.</blockquote>",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
f"<b>🛠 Админ-панель {html.escape(brand_name)}</b>",
|
||||
"",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"Пользователей в боте: <b>{context.admin_total_telegram_users}</b>\n"
|
||||
f"Доступов в кэше: <b>{context.admin_total_cached_users}</b>\n"
|
||||
f"Активных доступов: <b>{context.admin_active_cached_users}</b>\n"
|
||||
f"Заказов без чека: <b>{context.admin_pending_orders}</b>\n"
|
||||
f"Чеков на проверке: <b>{context.admin_review_orders}</b>\n"
|
||||
f"Открытых тикетов: <b>{context.admin_open_tickets}</b>\n"
|
||||
f"Бонусов в ожидании: <b>{context.admin_pending_referral_bonuses}</b>"
|
||||
"</blockquote>"
|
||||
),
|
||||
"",
|
||||
"<i>Используйте кнопки ниже для поиска пользователей, синхронизации и перехода в рабочие чаты.</i>",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _render_subscription_caption(*, context: PanelContext) -> str:
|
||||
lines = ["<b>💳 Подписка</b>"]
|
||||
|
||||
if not context.users:
|
||||
if context.payment_enabled:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<blockquote>У вас пока нет активного доступа.</blockquote>",
|
||||
"",
|
||||
format_payment_plan(
|
||||
title=context.payment_plan_title,
|
||||
description=context.payment_plan_description,
|
||||
price_stars=context.payment_plan_price_stars,
|
||||
duration_days=context.payment_plan_duration_days,
|
||||
traffic_limit_bytes=context.payment_plan_traffic_limit_bytes,
|
||||
),
|
||||
"",
|
||||
(
|
||||
"<i>После выбора тарифа бот покажет реквизиты. "
|
||||
"Затем нужно отправить чек, а администратор подтвердит оплату и выдаст доступ.</i>"
|
||||
),
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.extend(
|
||||
[
|
||||
(
|
||||
"<blockquote>"
|
||||
"У вас пока нет активного доступа.\n\n"
|
||||
"Если у вас уже есть подписка, откройте поддержку "
|
||||
"и отправьте short UUID из ссылки подписки."
|
||||
"</blockquote>"
|
||||
),
|
||||
"",
|
||||
"<i>Покупка пока не настроена в .env. Нужны параметры PAYMENT_*.</i>",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.append("<b>Ваши доступы</b>")
|
||||
for index, user in enumerate(context.users[:2], start=1):
|
||||
squad_text = ", ".join(user.internal_squads[:2]) if user.internal_squads else "не назначены"
|
||||
if len(squad_text) > 80:
|
||||
squad_text = f"{squad_text[:77]}..."
|
||||
lines.append(
|
||||
(
|
||||
f"{index}. <b>{html.escape(user.record.username)}</b> — "
|
||||
f"{_status_badge(user.record.status)}\n"
|
||||
"<blockquote>"
|
||||
f"Истекает: <b>{format_datetime(user.record.expire_at)}</b>\n"
|
||||
f"Трафик: <b>{format_bytes(user.record.used_traffic_bytes)}</b> / "
|
||||
f"<b>{format_traffic_limit(user.record.traffic_limit_bytes)}</b>\n"
|
||||
f"Short UUID: <code>{html.escape(user.record.short_uuid)}</code>\n"
|
||||
f"Группы: {html.escape(squad_text)}"
|
||||
"</blockquote>"
|
||||
)
|
||||
)
|
||||
|
||||
if len(context.users) > 2:
|
||||
lines.append(f"<i>И ещё аккаунтов: {len(context.users) - 2}</i>")
|
||||
|
||||
if context.payment_enabled:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
(
|
||||
"<i>"
|
||||
"Чтобы продлить текущую подписку, используйте кнопку "
|
||||
"«Продлить подписку». После подтверждения оплаты бот "
|
||||
"автоматически добавит выбранный срок к вашему доступу."
|
||||
"</i>"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
if context.api_error:
|
||||
lines.append("")
|
||||
lines.append("<i>Последнее обновление пришло из кэша.</i>")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_referral_caption(*, context: PanelContext) -> str:
|
||||
if not context.referral_enabled:
|
||||
return "\n".join(
|
||||
[
|
||||
"<b>👥 Реферальная система</b>",
|
||||
"",
|
||||
"<blockquote>Реферальная система сейчас отключена администратором.</blockquote>",
|
||||
]
|
||||
)
|
||||
|
||||
lines = [
|
||||
"<b>👥 Реферальная система</b>",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"Ваш реферальный код: <code>{html.escape(context.referral_code or '—')}</code>\n"
|
||||
f"Приглашено пользователей: <b>{context.referral_count}</b>"
|
||||
"</blockquote>"
|
||||
),
|
||||
]
|
||||
|
||||
if context.referral_bonus_days > 0:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"🎁 Бонус за подтверждённую оплату по вашему коду: "
|
||||
f"<b>+{context.referral_bonus_days} дней</b>"
|
||||
"</blockquote>"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
if context.applied_referral_code:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<b>Активированная скидка</b>",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"Код: <code>{html.escape(context.applied_referral_code)}</code>\n"
|
||||
f"Скидка перед оплатой: <b>{max(context.referral_discount_percent, 0)}%</b>"
|
||||
"</blockquote>"
|
||||
),
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
(
|
||||
"<i>"
|
||||
f"Чтобы получить скидку {max(context.referral_discount_percent, 0)}%, "
|
||||
"код нужно ввести вручную до оплаты."
|
||||
"</i>"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
if context.referral_link:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<b>Ваша ссылка</b>",
|
||||
f"<blockquote><code>{html.escape(context.referral_link)}</code></blockquote>",
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<i>Для генерации ссылки укажите BOT_PUBLIC_USERNAME в .env.</i>",
|
||||
]
|
||||
)
|
||||
|
||||
if context.referral_recent_names:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<b>Последние приглашения</b>",
|
||||
"<blockquote>" + "\n".join(html.escape(name) for name in context.referral_recent_names) + "</blockquote>",
|
||||
]
|
||||
)
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<i>Переход по ссылке сам по себе не активирует скидку. Код нужно ввести вручную перед оплатой.</i>",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_terms_caption() -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"<b>📜 Правила VPN</b>",
|
||||
(
|
||||
"<blockquote>"
|
||||
"1. Используя сервис и бота, вы подтверждаете согласие с правилами платформы и обязуетесь использовать VPN только в законных целях.\n"
|
||||
"2. VPN предназначен для защиты данных, приватности и безопасного доступа к интернет-ресурсам.\n"
|
||||
"3. Запрещены передача доступа третьим лицам, спам, взлом, мошенничество, распространение вредоносного ПО, атаки и любой незаконный контент.\n"
|
||||
"4. Пользователь самостоятельно несёт ответственность за свои действия при использовании сервиса.\n"
|
||||
"5. Для стабильной работы могут обрабатываться технические данные подключения без передачи третьим лицам, кроме случаев, предусмотренных законодательством.\n"
|
||||
"6. При нарушении правил доступ может быть ограничен или приостановлен без возврата средств.\n\n"
|
||||
"⚠️ VPN повышает уровень конфиденциальности, но не гарантирует полной анонимности."
|
||||
"</blockquote>"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _render_support_caption() -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"<b>🆘 Помощь и сопровождение</b>",
|
||||
(
|
||||
"<blockquote>"
|
||||
"Здесь вы можете обратиться в поддержку по вопросам доступа, оплаты, подключения и работы сервиса.\n\n"
|
||||
"Если нужна помощь, откройте тикет через кнопку ниже."
|
||||
"</blockquote>"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _render_support_ticket_caption() -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"<b>🎫 Создание тикета</b>",
|
||||
(
|
||||
"<blockquote>"
|
||||
"Опишите проблему как можно подробнее: устройство, приложение, текст ошибки и что именно не работает.\n\n"
|
||||
"После нажатия кнопки ниже бот переведёт вас в режим создания тикета, и следующее сообщение будет отправлено в поддержку."
|
||||
"</blockquote>"
|
||||
),
|
||||
]
|
||||
)
|
||||
305
app/config.py
Normal file
305
app/config.py
Normal file
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from urllib.parse import quote_plus, urlparse
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PaymentPlanSettings:
|
||||
code: str
|
||||
days: int
|
||||
amount_rub: int
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
bot_token: str = Field(validation_alias="BOT_TOKEN")
|
||||
bot_admin_ids_raw: str = Field(default="", validation_alias="BOT_ADMIN_IDS")
|
||||
bot_moderator_ids_raw: str = Field(default="", validation_alias="BOT_MODERATOR_IDS")
|
||||
telegram_proxy_url: str = Field(default="", validation_alias="TELEGRAM_PROXY_URL")
|
||||
bot_brand_name: str = Field(default="OREOL VPN", validation_alias="BOT_BRAND_NAME")
|
||||
bot_public_username: str = Field(default="", validation_alias="BOT_PUBLIC_USERNAME")
|
||||
bot_support_url: str = Field(default="", validation_alias="BOT_SUPPORT_URL")
|
||||
bot_terms_url: str = Field(default="", validation_alias="BOT_TERMS_URL")
|
||||
bot_support_ticket_link: str = Field(default="", validation_alias="BOT_SUPPORT_TICKET_LINK")
|
||||
bot_support_ticket_chat_id_raw: str = Field(
|
||||
default="",
|
||||
validation_alias="BOT_SUPPORT_TICKET_CHAT_ID",
|
||||
)
|
||||
bot_support_ticket_thread_id: int = Field(
|
||||
default=0,
|
||||
validation_alias="BOT_SUPPORT_TICKET_THREAD_ID",
|
||||
)
|
||||
bot_start_image_enabled: bool = Field(
|
||||
default=True,
|
||||
validation_alias="BOT_START_IMAGE_ENABLED",
|
||||
)
|
||||
bot_start_image_path: str = Field(default="", validation_alias="BOT_START_IMAGE_PATH")
|
||||
|
||||
remnawave_base_url: str = Field(validation_alias="REMNAWAVE_BASE_URL")
|
||||
remnawave_api_token: str = Field(default="", validation_alias="REMNAWAVE_API_TOKEN")
|
||||
remnawave_caddy_api_key: str = Field(default="", validation_alias="REMNAWAVE_CADDY_API_KEY")
|
||||
remnawave_timeout_seconds: float = Field(
|
||||
default=20.0,
|
||||
validation_alias="REMNAWAVE_TIMEOUT_SECONDS",
|
||||
)
|
||||
|
||||
db_host: str = Field(default="127.0.0.1", validation_alias="DB_HOST")
|
||||
db_port: int = Field(default=3306, validation_alias="DB_PORT")
|
||||
db_name: str = Field(default="telegabot", validation_alias="DB_NAME")
|
||||
db_user: str = Field(default="telegabot", validation_alias="DB_USER")
|
||||
db_password: str = Field(default="", validation_alias="DB_PASSWORD")
|
||||
db_echo: bool = Field(default=False, validation_alias="DB_ECHO")
|
||||
create_database_on_start: bool = Field(
|
||||
default=True,
|
||||
validation_alias="CREATE_DATABASE_ON_START",
|
||||
)
|
||||
create_tables_on_start: bool = Field(
|
||||
default=True,
|
||||
validation_alias="CREATE_TABLES_ON_START",
|
||||
)
|
||||
sync_subscription_history: bool = Field(
|
||||
default=True,
|
||||
validation_alias="SYNC_SUBSCRIPTION_HISTORY",
|
||||
)
|
||||
sync_batch_size: int = Field(default=100, validation_alias="SYNC_BATCH_SIZE")
|
||||
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
|
||||
|
||||
payment_plans_raw: str = Field(
|
||||
default="30:250,180:600,365:1000",
|
||||
validation_alias="PAYMENT_PLANS",
|
||||
)
|
||||
payment_transfer_text: str = Field(
|
||||
default="Переведите оплату по указанным реквизитам и отправьте чек в бот.",
|
||||
validation_alias="PAYMENT_TRANSFER_TEXT",
|
||||
)
|
||||
payment_review_link: str = Field(default="", validation_alias="PAYMENT_REVIEW_LINK")
|
||||
payment_review_chat_id_raw: str = Field(
|
||||
default="",
|
||||
validation_alias="PAYMENT_REVIEW_CHAT_ID",
|
||||
)
|
||||
payment_review_thread_id: int = Field(
|
||||
default=0,
|
||||
validation_alias="PAYMENT_REVIEW_THREAD_ID",
|
||||
)
|
||||
referral_discount_percent: int = Field(
|
||||
default=5,
|
||||
validation_alias="REFERRAL_DISCOUNT_PERCENT",
|
||||
)
|
||||
referral_bonus_days: int = Field(
|
||||
default=7,
|
||||
validation_alias="REFERRAL_BONUS_DAYS",
|
||||
)
|
||||
payment_plan_code: str = Field(default="vpn_30d", validation_alias="PAYMENT_PLAN_CODE")
|
||||
payment_plan_title: str = Field(
|
||||
default="OREOL VPN на 30 дней",
|
||||
validation_alias="PAYMENT_PLAN_TITLE",
|
||||
)
|
||||
payment_plan_description: str = Field(
|
||||
default="Доступ к VPN на 30 дней",
|
||||
validation_alias="PAYMENT_PLAN_DESCRIPTION",
|
||||
)
|
||||
payment_plan_price_stars: int = Field(default=500, validation_alias="PAYMENT_PLAN_PRICE_STARS")
|
||||
payment_plan_duration_days: int = Field(
|
||||
default=30,
|
||||
validation_alias="PAYMENT_PLAN_DURATION_DAYS",
|
||||
)
|
||||
payment_plan_traffic_limit_gb: int = Field(
|
||||
default=0,
|
||||
validation_alias="PAYMENT_PLAN_TRAFFIC_LIMIT_GB",
|
||||
)
|
||||
payment_plan_traffic_reset_period: str = Field(
|
||||
default="NO_RESET",
|
||||
validation_alias="PAYMENT_PLAN_TRAFFIC_RESET_PERIOD",
|
||||
)
|
||||
payment_internal_squad_uuids_raw: str = Field(
|
||||
default="",
|
||||
validation_alias="PAYMENT_INTERNAL_SQUAD_UUIDS",
|
||||
)
|
||||
payment_external_squad_uuid: str = Field(
|
||||
default="",
|
||||
validation_alias="PAYMENT_EXTERNAL_SQUAD_UUID",
|
||||
)
|
||||
payment_support_text: str = Field(
|
||||
default="Если оплата прошла, но доступ не выдался, напишите в поддержку.",
|
||||
validation_alias="PAYMENT_SUPPORT_TEXT",
|
||||
)
|
||||
payment_username_prefix: str = Field(
|
||||
default="Oreol",
|
||||
validation_alias="PAYMENT_USERNAME_PREFIX",
|
||||
)
|
||||
payment_user_tag: str = Field(default="BOT", validation_alias="PAYMENT_USER_TAG")
|
||||
|
||||
@cached_property
|
||||
def bot_admin_ids(self) -> set[int]:
|
||||
values: set[int] = set()
|
||||
for raw_part in self.bot_admin_ids_raw.split(","):
|
||||
part = raw_part.strip()
|
||||
if part:
|
||||
values.add(int(part))
|
||||
return values
|
||||
|
||||
@cached_property
|
||||
def bot_moderator_ids(self) -> set[int]:
|
||||
values: set[int] = set()
|
||||
for raw_part in self.bot_moderator_ids_raw.split(","):
|
||||
part = raw_part.strip()
|
||||
if part:
|
||||
values.add(int(part))
|
||||
return values
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
user = quote_plus(self.db_user)
|
||||
password = quote_plus(self.db_password)
|
||||
return (
|
||||
f"mysql+aiomysql://{user}:{password}"
|
||||
f"@{self.db_host}:{self.db_port}/{self.db_name}?charset=utf8mb4"
|
||||
)
|
||||
|
||||
@property
|
||||
def database_server_url(self) -> str:
|
||||
user = quote_plus(self.db_user)
|
||||
password = quote_plus(self.db_password)
|
||||
return (
|
||||
f"mysql+aiomysql://{user}:{password}"
|
||||
f"@{self.db_host}:{self.db_port}/?charset=utf8mb4"
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def support_ticket_chat_id(self) -> int | None:
|
||||
if self.bot_support_ticket_chat_id_raw.strip():
|
||||
return int(self.bot_support_ticket_chat_id_raw.strip())
|
||||
|
||||
parsed_chat_id, _ = self._parse_private_topic_link(self.bot_support_ticket_link)
|
||||
return parsed_chat_id
|
||||
|
||||
@cached_property
|
||||
def support_ticket_message_thread_id(self) -> int | None:
|
||||
if self.bot_support_ticket_thread_id > 0:
|
||||
return self.bot_support_ticket_thread_id
|
||||
|
||||
_, parsed_thread_id = self._parse_private_topic_link(self.bot_support_ticket_link)
|
||||
return parsed_thread_id
|
||||
|
||||
@cached_property
|
||||
def payment_review_chat_id(self) -> int | None:
|
||||
if self.payment_review_chat_id_raw.strip():
|
||||
return int(self.payment_review_chat_id_raw.strip())
|
||||
|
||||
parsed_chat_id, _ = self._parse_private_topic_link(self.payment_review_link)
|
||||
return parsed_chat_id
|
||||
|
||||
@cached_property
|
||||
def payment_review_message_thread_id(self) -> int | None:
|
||||
if self.payment_review_thread_id > 0:
|
||||
return self.payment_review_thread_id
|
||||
|
||||
_, parsed_thread_id = self._parse_private_topic_link(self.payment_review_link)
|
||||
return parsed_thread_id
|
||||
|
||||
@staticmethod
|
||||
def _parse_private_topic_link(link: str) -> tuple[int | None, int | None]:
|
||||
cleaned = link.strip()
|
||||
if not cleaned:
|
||||
return None, None
|
||||
|
||||
parsed = urlparse(cleaned)
|
||||
if parsed.netloc not in {"t.me", "telegram.me", "www.t.me", "www.telegram.me"}:
|
||||
return None, None
|
||||
|
||||
parts = [part for part in parsed.path.split("/") if part]
|
||||
if len(parts) < 3 or parts[0] != "c" or not parts[1].isdigit():
|
||||
return None, None
|
||||
|
||||
chat_id = int(f"-100{parts[1]}")
|
||||
thread_id = int(parts[2]) if len(parts) >= 4 and parts[2].isdigit() else None
|
||||
return chat_id, thread_id
|
||||
|
||||
def is_admin(self, telegram_id: int | None) -> bool:
|
||||
return telegram_id is not None and telegram_id in self.bot_admin_ids
|
||||
|
||||
def is_moderator(self, telegram_id: int | None) -> bool:
|
||||
return telegram_id is not None and telegram_id in self.bot_moderator_ids
|
||||
|
||||
@property
|
||||
def bot_public_username_normalized(self) -> str:
|
||||
return self.bot_public_username.strip().removeprefix("@")
|
||||
|
||||
@cached_property
|
||||
def payment_internal_squad_uuids(self) -> list[str]:
|
||||
values: list[str] = []
|
||||
for raw_part in self.payment_internal_squad_uuids_raw.split(","):
|
||||
part = raw_part.strip()
|
||||
if part:
|
||||
values.append(part)
|
||||
return values
|
||||
|
||||
@cached_property
|
||||
def payment_external_squad_uuid_normalized(self) -> str | None:
|
||||
value = self.payment_external_squad_uuid.strip()
|
||||
return value or None
|
||||
|
||||
@cached_property
|
||||
def payment_username_prefix_normalized(self) -> str:
|
||||
normalized = "".join(
|
||||
ch for ch in self.payment_username_prefix.strip() if ch.isalnum() or ch in "_-"
|
||||
)
|
||||
return normalized[:12] or "Oreol"
|
||||
|
||||
@cached_property
|
||||
def payment_user_tag_normalized(self) -> str | None:
|
||||
normalized = "".join(
|
||||
ch for ch in self.payment_user_tag.strip().upper() if ch.isalnum() or ch == "_"
|
||||
)
|
||||
return normalized[:16] or None
|
||||
|
||||
@cached_property
|
||||
def payment_plans(self) -> list[PaymentPlanSettings]:
|
||||
values: list[PaymentPlanSettings] = []
|
||||
for raw_part in self.payment_plans_raw.split(","):
|
||||
part = raw_part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
fragments = [fragment.strip() for fragment in part.split(":")]
|
||||
if len(fragments) != 2 or not fragments[0].isdigit() or not fragments[1].isdigit():
|
||||
continue
|
||||
|
||||
days = int(fragments[0])
|
||||
amount_rub = int(fragments[1])
|
||||
if days <= 0 or amount_rub <= 0:
|
||||
continue
|
||||
|
||||
values.append(
|
||||
PaymentPlanSettings(
|
||||
code=f"{days}d",
|
||||
days=days,
|
||||
amount_rub=amount_rub,
|
||||
)
|
||||
)
|
||||
|
||||
if values:
|
||||
return sorted(values, key=lambda item: item.days)
|
||||
|
||||
fallback_amount = max(self.payment_plan_price_stars, 0)
|
||||
fallback_days = max(self.payment_plan_duration_days, 0)
|
||||
if fallback_amount > 0 and fallback_days > 0:
|
||||
return [
|
||||
PaymentPlanSettings(
|
||||
code=self.payment_plan_code.strip() or f"{fallback_days}d",
|
||||
days=fallback_days,
|
||||
amount_rub=fallback_amount,
|
||||
)
|
||||
]
|
||||
|
||||
return []
|
||||
1
app/db/__init__.py
Normal file
1
app/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Database package."""
|
||||
11
app/db/base.py
Normal file
11
app/db/base.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for ORM models."""
|
||||
238
app/db/models.py
Normal file
238
app/db/models.py
Normal file
@@ -0,0 +1,238 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, utcnow
|
||||
|
||||
|
||||
class TelegramUser(Base):
|
||||
__tablename__ = "telegram_users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
|
||||
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
first_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
last_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
language_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class RemnawaveUser(Base):
|
||||
__tablename__ = "remnawave_users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
owner_telegram_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("telegram_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
rw_uuid: Mapped[str] = mapped_column(String(36), nullable=False, unique=True, index=True)
|
||||
rw_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True)
|
||||
short_uuid: Mapped[str] = mapped_column(String(48), nullable=False, unique=True, index=True)
|
||||
username: Mapped[str] = mapped_column(String(36), nullable=False, unique=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
|
||||
traffic_limit_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
used_traffic_bytes: Mapped[float] = mapped_column(Float(asdecimal=False), nullable=False, default=0.0)
|
||||
lifetime_used_traffic_bytes: Mapped[float] = mapped_column(Float(asdecimal=False), nullable=False, default=0.0)
|
||||
expire_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
telegram_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
|
||||
email: Mapped[str | None] = mapped_column(String(320), nullable=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tag: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
||||
hwid_device_limit: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
external_squad_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
|
||||
trojan_password: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
vless_uuid: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
ss_password: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
last_triggered_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
sub_revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
sub_last_user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
sub_last_opened_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_traffic_reset_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
subscription_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
online_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
first_connected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_connected_node_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
|
||||
remote_created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
remote_updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
synced_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
|
||||
|
||||
class InternalSquad(Base):
|
||||
__tablename__ = "internal_squads"
|
||||
|
||||
uuid: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
synced_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
|
||||
|
||||
class RemnawaveUserInternalSquad(Base):
|
||||
__tablename__ = "remnawave_user_internal_squads"
|
||||
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("remnawave_users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
squad_uuid: Mapped[str] = mapped_column(
|
||||
ForeignKey("internal_squads.uuid", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionRequestLog(Base):
|
||||
__tablename__ = "subscription_request_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
remote_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
|
||||
user_uuid: Mapped[str] = mapped_column(
|
||||
ForeignKey("remnawave_users.rw_uuid", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
request_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
request_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
synced_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
|
||||
|
||||
class SupportTicket(Base):
|
||||
__tablename__ = "support_tickets"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_support_tickets_chat_message",
|
||||
"support_chat_id",
|
||||
"support_message_id",
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
public_id: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, index=True)
|
||||
|
||||
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
user_message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
support_chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
support_thread_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
support_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="OPEN")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class ReferralInvite(Base):
|
||||
__tablename__ = "referral_invites"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
inviter_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
invited_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
|
||||
invited_username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
invited_display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
|
||||
|
||||
class ReferralCode(Base):
|
||||
__tablename__ = "referral_codes"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
|
||||
code: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class ReferralBonus(Base):
|
||||
__tablename__ = "referral_bonuses"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
inviter_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
invited_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
source_order_uuid: Mapped[str] = mapped_column(String(36), nullable=False, unique=True, index=True)
|
||||
bonus_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="PENDING", index=True)
|
||||
applied_to_user_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
applied_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class BotSetting(Base):
|
||||
__tablename__ = "bot_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||
value: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
updated_by_telegram_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class SubscriptionNotification(Base):
|
||||
__tablename__ = "subscription_notifications"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_sub_notif_unique",
|
||||
"remnawave_user_id",
|
||||
"notification_type",
|
||||
"expire_at_snapshot",
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
remnawave_user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("remnawave_users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
notification_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
expire_at_snapshot: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
sent_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
|
||||
|
||||
class PaymentOrder(Base):
|
||||
__tablename__ = "payment_orders"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
order_uuid: Mapped[str] = mapped_column(String(36), nullable=False, unique=True, index=True)
|
||||
telegram_user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("telegram_users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
plan_code: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
plan_title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
plan_duration_days: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
traffic_limit_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
traffic_limit_strategy: Mapped[str] = mapped_column(String(32), nullable=False, default="NO_RESET")
|
||||
amount_stars: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(8), nullable=False, default="XTR")
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="PENDING", index=True)
|
||||
invoice_payload: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
|
||||
provision_username: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
remnawave_user_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
subscription_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
telegram_payment_charge_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
|
||||
provider_payment_charge_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
paid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
fulfilled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
152
app/db/session.py
Normal file
152
app/db/session.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncConnection,
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db import models as _models # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PAYMENT_ORDERS_TABLE_NAME = "payment_orders"
|
||||
PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN = "provision_username"
|
||||
PAYMENT_ORDERS_PROVISION_USERNAME_INDEX = "ix_payment_orders_provision_username"
|
||||
|
||||
|
||||
def create_engine_and_session_factory(
|
||||
database_url: str,
|
||||
*,
|
||||
echo: bool = False,
|
||||
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
|
||||
engine = create_async_engine(
|
||||
database_url,
|
||||
echo=echo,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||
return engine, session_factory
|
||||
|
||||
|
||||
def _quote_mysql_identifier(identifier: str) -> str:
|
||||
return f"`{identifier.replace('`', '``')}`"
|
||||
|
||||
|
||||
async def _get_mysql_single_column_indexes(
|
||||
connection: AsyncConnection,
|
||||
*,
|
||||
table_name: str,
|
||||
column_name: str,
|
||||
non_unique: int,
|
||||
) -> list[str]:
|
||||
result = await connection.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT index_name
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = :table_name
|
||||
GROUP BY index_name, non_unique
|
||||
HAVING non_unique = :non_unique
|
||||
AND COUNT(*) = 1
|
||||
AND MAX(CASE WHEN column_name = :column_name THEN 1 ELSE 0 END) = 1
|
||||
"""
|
||||
),
|
||||
{
|
||||
"table_name": table_name,
|
||||
"column_name": column_name,
|
||||
"non_unique": non_unique,
|
||||
},
|
||||
)
|
||||
return [row[0] for row in result]
|
||||
|
||||
|
||||
async def _normalize_payment_order_provision_username_index(
|
||||
connection: AsyncConnection,
|
||||
) -> None:
|
||||
if connection.dialect.name != "mysql":
|
||||
return
|
||||
|
||||
unique_index_names = await _get_mysql_single_column_indexes(
|
||||
connection,
|
||||
table_name=PAYMENT_ORDERS_TABLE_NAME,
|
||||
column_name=PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN,
|
||||
non_unique=0,
|
||||
)
|
||||
|
||||
quoted_table_name = _quote_mysql_identifier(PAYMENT_ORDERS_TABLE_NAME)
|
||||
for index_name in unique_index_names:
|
||||
logger.info(
|
||||
"Dropping stale UNIQUE index %s on %s.%s",
|
||||
index_name,
|
||||
PAYMENT_ORDERS_TABLE_NAME,
|
||||
PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN,
|
||||
)
|
||||
await connection.execute(
|
||||
text(
|
||||
"DROP INDEX "
|
||||
f"{_quote_mysql_identifier(index_name)} "
|
||||
f"ON {quoted_table_name}"
|
||||
)
|
||||
)
|
||||
|
||||
non_unique_index_names = await _get_mysql_single_column_indexes(
|
||||
connection,
|
||||
table_name=PAYMENT_ORDERS_TABLE_NAME,
|
||||
column_name=PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN,
|
||||
non_unique=1,
|
||||
)
|
||||
if non_unique_index_names:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Creating non-unique index %s on %s.%s",
|
||||
PAYMENT_ORDERS_PROVISION_USERNAME_INDEX,
|
||||
PAYMENT_ORDERS_TABLE_NAME,
|
||||
PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN,
|
||||
)
|
||||
await connection.execute(
|
||||
text(
|
||||
"CREATE INDEX "
|
||||
f"{_quote_mysql_identifier(PAYMENT_ORDERS_PROVISION_USERNAME_INDEX)} "
|
||||
f"ON {quoted_table_name} "
|
||||
f"({_quote_mysql_identifier(PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN)})"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def ensure_database_exists(
|
||||
server_url: str,
|
||||
database_name: str,
|
||||
*,
|
||||
echo: bool = False,
|
||||
) -> None:
|
||||
engine = create_async_engine(
|
||||
server_url,
|
||||
echo=echo,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
try:
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(
|
||||
text(
|
||||
"CREATE DATABASE IF NOT EXISTS "
|
||||
f"{_quote_mysql_identifier(database_name)} "
|
||||
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def init_db(engine: AsyncEngine) -> None:
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
await _normalize_payment_order_provision_username_index(connection)
|
||||
135
app/main.py
Normal file
135
app/main.py
Normal file
@@ -0,0 +1,135 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramNetworkError
|
||||
from aiogram.types import BotCommand
|
||||
|
||||
from app.bot.handlers.main import build_router
|
||||
from app.config import Settings
|
||||
from app.db.session import create_engine_and_session_factory, ensure_database_exists, init_db
|
||||
from app.services.bot_config_service import BotConfigService
|
||||
from app.services.notification_service import SubscriptionNotificationService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.remnawave_client import RemnawaveApiClient
|
||||
from app.services.support_ticket_service import SupportTicketService
|
||||
from app.services.sync_service import SyncService
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
settings = Settings()
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
||||
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
engine = None
|
||||
remnawave_client = None
|
||||
bot = None
|
||||
|
||||
try:
|
||||
if settings.create_database_on_start:
|
||||
logger.info(
|
||||
"Ensuring MariaDB database '%s' exists on %s:%s",
|
||||
settings.db_name,
|
||||
settings.db_host,
|
||||
settings.db_port,
|
||||
)
|
||||
await ensure_database_exists(
|
||||
settings.database_server_url,
|
||||
settings.db_name,
|
||||
echo=settings.db_echo,
|
||||
)
|
||||
|
||||
engine, session_factory = create_engine_and_session_factory(
|
||||
settings.database_url,
|
||||
echo=settings.db_echo,
|
||||
)
|
||||
|
||||
if settings.create_tables_on_start:
|
||||
await init_db(engine)
|
||||
|
||||
remnawave_client = RemnawaveApiClient(
|
||||
base_url=settings.remnawave_base_url,
|
||||
api_token=settings.remnawave_api_token,
|
||||
caddy_api_key=settings.remnawave_caddy_api_key,
|
||||
timeout_seconds=settings.remnawave_timeout_seconds,
|
||||
)
|
||||
sync_service = SyncService(
|
||||
settings=settings,
|
||||
session_factory=session_factory,
|
||||
remnawave_client=remnawave_client,
|
||||
)
|
||||
bot_config_service = BotConfigService(
|
||||
settings=settings,
|
||||
session_factory=session_factory,
|
||||
)
|
||||
support_ticket_service = SupportTicketService(session_factory=session_factory)
|
||||
payment_service = PaymentService(
|
||||
settings=settings,
|
||||
session_factory=session_factory,
|
||||
remnawave_client=remnawave_client,
|
||||
sync_service=sync_service,
|
||||
config_service=bot_config_service,
|
||||
)
|
||||
|
||||
bot_session = AiohttpSession(proxy=settings.telegram_proxy_url or None)
|
||||
bot = Bot(
|
||||
token=settings.bot_token,
|
||||
session=bot_session,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
dispatcher = Dispatcher()
|
||||
dispatcher.include_router(
|
||||
build_router(
|
||||
settings=settings,
|
||||
sync_service=sync_service,
|
||||
support_ticket_service=support_ticket_service,
|
||||
payment_service=payment_service,
|
||||
bot_config_service=bot_config_service,
|
||||
)
|
||||
)
|
||||
|
||||
await bot.set_my_commands(
|
||||
[
|
||||
BotCommand(command="start", description="Открыть главное меню"),
|
||||
]
|
||||
)
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
notification_service = SubscriptionNotificationService(
|
||||
session_factory=session_factory,
|
||||
sync_service=sync_service,
|
||||
config_service=bot_config_service,
|
||||
project_root=project_root,
|
||||
)
|
||||
notification_task = asyncio.create_task(
|
||||
notification_service.run_periodic(bot),
|
||||
)
|
||||
|
||||
try:
|
||||
await dispatcher.start_polling(bot)
|
||||
finally:
|
||||
notification_task.cancel()
|
||||
try:
|
||||
await notification_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except TelegramNetworkError:
|
||||
logger.exception(
|
||||
"Cannot reach Telegram API. Check outbound HTTPS access to api.telegram.org:443, DNS, firewall, VPN, or proxy."
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if remnawave_client is not None:
|
||||
await remnawave_client.close()
|
||||
if bot is not None:
|
||||
await bot.session.close()
|
||||
if engine is not None:
|
||||
await engine.dispose()
|
||||
1
app/schemas/__init__.py
Normal file
1
app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Pydantic schemas."""
|
||||
75
app/schemas/remnawave.py
Normal file
75
app/schemas/remnawave.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class RemnawaveBaseModel(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
||||
|
||||
|
||||
class ActiveInternalSquad(RemnawaveBaseModel):
|
||||
uuid: UUID
|
||||
name: str
|
||||
|
||||
|
||||
class UserTraffic(RemnawaveBaseModel):
|
||||
used_traffic_bytes: float = Field(alias="usedTrafficBytes")
|
||||
lifetime_used_traffic_bytes: float = Field(alias="lifetimeUsedTrafficBytes")
|
||||
online_at: datetime | None = Field(default=None, alias="onlineAt")
|
||||
first_connected_at: datetime | None = Field(default=None, alias="firstConnectedAt")
|
||||
last_connected_node_uuid: UUID | None = Field(default=None, alias="lastConnectedNodeUuid")
|
||||
|
||||
|
||||
class RemnawaveUser(RemnawaveBaseModel):
|
||||
uuid: UUID
|
||||
id: int
|
||||
short_uuid: str = Field(alias="shortUuid")
|
||||
username: str
|
||||
status: str
|
||||
traffic_limit_bytes: int = Field(alias="trafficLimitBytes")
|
||||
expire_at: datetime = Field(alias="expireAt")
|
||||
telegram_id: int | None = Field(default=None, alias="telegramId")
|
||||
email: str | None = None
|
||||
description: str | None = None
|
||||
tag: str | None = None
|
||||
hwid_device_limit: int | None = Field(default=None, alias="hwidDeviceLimit")
|
||||
external_squad_uuid: UUID | None = Field(default=None, alias="externalSquadUuid")
|
||||
trojan_password: str = Field(alias="trojanPassword")
|
||||
vless_uuid: UUID = Field(alias="vlessUuid")
|
||||
ss_password: str = Field(alias="ssPassword")
|
||||
last_triggered_threshold: int = Field(alias="lastTriggeredThreshold")
|
||||
sub_revoked_at: datetime | None = Field(default=None, alias="subRevokedAt")
|
||||
sub_last_user_agent: str | None = Field(default=None, alias="subLastUserAgent")
|
||||
sub_last_opened_at: datetime | None = Field(default=None, alias="subLastOpenedAt")
|
||||
last_traffic_reset_at: datetime | None = Field(default=None, alias="lastTrafficResetAt")
|
||||
created_at: datetime = Field(alias="createdAt")
|
||||
updated_at: datetime = Field(alias="updatedAt")
|
||||
subscription_url: str = Field(alias="subscriptionUrl")
|
||||
active_internal_squads: list[ActiveInternalSquad] = Field(alias="activeInternalSquads")
|
||||
user_traffic: UserTraffic = Field(alias="userTraffic")
|
||||
|
||||
|
||||
class PaginatedUsers(RemnawaveBaseModel):
|
||||
users: list[RemnawaveUser]
|
||||
total: int
|
||||
|
||||
|
||||
class ResolvedUser(RemnawaveBaseModel):
|
||||
uuid: UUID
|
||||
username: str
|
||||
id: int
|
||||
short_uuid: str = Field(alias="shortUuid")
|
||||
|
||||
|
||||
class SubscriptionRequestRecord(RemnawaveBaseModel):
|
||||
id: int
|
||||
user_uuid: UUID = Field(alias="userUuid")
|
||||
request_at: datetime = Field(alias="requestAt")
|
||||
request_ip: str | None = Field(default=None, alias="requestIp")
|
||||
user_agent: str | None = Field(default=None, alias="userAgent")
|
||||
|
||||
|
||||
class SubscriptionRequestHistory(RemnawaveBaseModel):
|
||||
total: int
|
||||
records: list[SubscriptionRequestRecord]
|
||||
1
app/services/__init__.py
Normal file
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Services package."""
|
||||
744
app/services/bot_config_service.py
Normal file
744
app/services/bot_config_service.py
Normal file
@@ -0,0 +1,744 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.config import PaymentPlanSettings, Settings
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import BotSetting
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BotConfigFieldSpec:
|
||||
key: str
|
||||
label: str
|
||||
section: str
|
||||
prompt: str
|
||||
description: str
|
||||
placeholder: str = ""
|
||||
is_secret_like: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BotConfigSnapshot:
|
||||
bot_brand_name: str
|
||||
bot_public_username: str
|
||||
bot_support_url: str
|
||||
bot_terms_url: str
|
||||
bot_support_ticket_link: str
|
||||
payment_review_link: str
|
||||
bot_start_image_enabled: bool
|
||||
bot_start_image_path: str
|
||||
referral_enabled: bool
|
||||
referral_discount_percent: int
|
||||
referral_bonus_days: int
|
||||
payment_plans_raw: str
|
||||
payment_transfer_text: str
|
||||
payment_support_text: str
|
||||
payment_plan_traffic_limit_gb: int
|
||||
payment_plan_traffic_reset_period: str
|
||||
payment_internal_squad_uuids_raw: str
|
||||
payment_external_squad_uuid: str
|
||||
payment_username_prefix: str
|
||||
payment_user_tag: str
|
||||
notification_enabled: bool = True
|
||||
notification_days_before: str = "1,3"
|
||||
notification_check_interval_hours: int = 6
|
||||
fallback_support_ticket_chat_id: int | None = None
|
||||
fallback_support_ticket_thread_id: int | None = None
|
||||
fallback_payment_review_chat_id: int | None = None
|
||||
fallback_payment_review_thread_id: int | None = None
|
||||
|
||||
@property
|
||||
def bot_public_username_normalized(self) -> str:
|
||||
return self.bot_public_username.strip().removeprefix("@")
|
||||
|
||||
@property
|
||||
def payment_external_squad_uuid_normalized(self) -> str | None:
|
||||
value = self.payment_external_squad_uuid.strip()
|
||||
return value or None
|
||||
|
||||
@property
|
||||
def payment_username_prefix_normalized(self) -> str:
|
||||
normalized = "".join(
|
||||
ch for ch in self.payment_username_prefix.strip() if ch.isalnum() or ch in "_-"
|
||||
)
|
||||
return normalized[:12] or "Oreol"
|
||||
|
||||
@property
|
||||
def payment_user_tag_normalized(self) -> str | None:
|
||||
normalized = "".join(
|
||||
ch for ch in self.payment_user_tag.strip().upper() if ch.isalnum() or ch == "_"
|
||||
)
|
||||
return normalized[:16] or None
|
||||
|
||||
@property
|
||||
def payment_internal_squad_uuids(self) -> list[str]:
|
||||
values: list[str] = []
|
||||
for raw_part in self.payment_internal_squad_uuids_raw.split(","):
|
||||
part = raw_part.strip()
|
||||
if part:
|
||||
values.append(part)
|
||||
return values
|
||||
|
||||
@property
|
||||
def notification_days_list(self) -> list[int]:
|
||||
values: list[int] = []
|
||||
for raw_part in self.notification_days_before.split(","):
|
||||
part = raw_part.strip()
|
||||
if part.isdigit():
|
||||
day = int(part)
|
||||
if day > 0:
|
||||
values.append(day)
|
||||
return sorted(set(values), reverse=True) if values else [1, 3]
|
||||
|
||||
@property
|
||||
def payment_plans(self) -> list[PaymentPlanSettings]:
|
||||
values: list[PaymentPlanSettings] = []
|
||||
for raw_part in self.payment_plans_raw.split(","):
|
||||
part = raw_part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
fragments = [fragment.strip() for fragment in part.split(":")]
|
||||
if len(fragments) != 2 or not fragments[0].isdigit() or not fragments[1].isdigit():
|
||||
continue
|
||||
|
||||
days = int(fragments[0])
|
||||
amount_rub = int(fragments[1])
|
||||
if days <= 0 or amount_rub <= 0:
|
||||
continue
|
||||
|
||||
values.append(
|
||||
PaymentPlanSettings(
|
||||
code=f"{days}d",
|
||||
days=days,
|
||||
amount_rub=amount_rub,
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(values, key=lambda item: item.days)
|
||||
|
||||
@staticmethod
|
||||
def _compact_text(raw_value: str, *, max_length: int = 80) -> str:
|
||||
normalized = " ".join(raw_value.split()).strip()
|
||||
if not normalized:
|
||||
return "-"
|
||||
if len(normalized) <= max_length:
|
||||
return normalized
|
||||
return f"{normalized[: max_length - 3]}..."
|
||||
|
||||
@property
|
||||
def support_ticket_chat_id(self) -> int | None:
|
||||
parsed_chat_id, _ = Settings._parse_private_topic_link(self.bot_support_ticket_link)
|
||||
return parsed_chat_id if parsed_chat_id is not None else self.fallback_support_ticket_chat_id
|
||||
|
||||
@property
|
||||
def support_ticket_message_thread_id(self) -> int | None:
|
||||
_, parsed_thread_id = Settings._parse_private_topic_link(self.bot_support_ticket_link)
|
||||
return (
|
||||
parsed_thread_id
|
||||
if parsed_thread_id is not None
|
||||
else self.fallback_support_ticket_thread_id
|
||||
)
|
||||
|
||||
@property
|
||||
def payment_review_chat_id(self) -> int | None:
|
||||
parsed_chat_id, _ = Settings._parse_private_topic_link(self.payment_review_link)
|
||||
return parsed_chat_id if parsed_chat_id is not None else self.fallback_payment_review_chat_id
|
||||
|
||||
@property
|
||||
def payment_review_message_thread_id(self) -> int | None:
|
||||
_, parsed_thread_id = Settings._parse_private_topic_link(self.payment_review_link)
|
||||
return (
|
||||
parsed_thread_id
|
||||
if parsed_thread_id is not None
|
||||
else self.fallback_payment_review_thread_id
|
||||
)
|
||||
|
||||
|
||||
class StaticBotConfigService:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
|
||||
async def get_snapshot(self) -> BotConfigSnapshot:
|
||||
return BotConfigSnapshot(
|
||||
bot_brand_name=self._settings.bot_brand_name,
|
||||
bot_public_username=self._settings.bot_public_username,
|
||||
bot_support_url=self._settings.bot_support_url,
|
||||
bot_terms_url=self._settings.bot_terms_url,
|
||||
bot_support_ticket_link=self._settings.bot_support_ticket_link,
|
||||
payment_review_link=self._settings.payment_review_link,
|
||||
bot_start_image_enabled=self._settings.bot_start_image_enabled,
|
||||
bot_start_image_path=self._settings.bot_start_image_path,
|
||||
referral_enabled=True,
|
||||
referral_discount_percent=self._settings.referral_discount_percent,
|
||||
referral_bonus_days=self._settings.referral_bonus_days,
|
||||
payment_plans_raw=self._settings.payment_plans_raw,
|
||||
payment_transfer_text=self._settings.payment_transfer_text,
|
||||
payment_support_text=self._settings.payment_support_text,
|
||||
payment_plan_traffic_limit_gb=self._settings.payment_plan_traffic_limit_gb,
|
||||
payment_plan_traffic_reset_period=self._settings.payment_plan_traffic_reset_period,
|
||||
notification_enabled=True,
|
||||
notification_days_before="1,3",
|
||||
notification_check_interval_hours=6,
|
||||
payment_internal_squad_uuids_raw=self._settings.payment_internal_squad_uuids_raw,
|
||||
payment_external_squad_uuid=self._settings.payment_external_squad_uuid,
|
||||
payment_username_prefix=self._settings.payment_username_prefix,
|
||||
payment_user_tag=self._settings.payment_user_tag,
|
||||
fallback_support_ticket_chat_id=self._settings.support_ticket_chat_id,
|
||||
fallback_support_ticket_thread_id=self._settings.support_ticket_message_thread_id,
|
||||
fallback_payment_review_chat_id=self._settings.payment_review_chat_id,
|
||||
fallback_payment_review_thread_id=self._settings.payment_review_message_thread_id,
|
||||
)
|
||||
|
||||
|
||||
class BotConfigService(StaticBotConfigService):
|
||||
FIELD_SPECS: dict[str, BotConfigFieldSpec] = {
|
||||
"bot_brand_name": BotConfigFieldSpec(
|
||||
key="bot_brand_name",
|
||||
label="Название бренда",
|
||||
section="brand",
|
||||
prompt="Введите новое название бренда.",
|
||||
description="Используется в главной панели и пользовательских сообщениях.",
|
||||
placeholder="OREOL VPN",
|
||||
),
|
||||
"bot_public_username": BotConfigFieldSpec(
|
||||
key="bot_public_username",
|
||||
label="Username бота",
|
||||
section="brand",
|
||||
prompt="Введите username бота без @. Для очистки отправьте `-`.",
|
||||
description="Нужен для генерации реферальных ссылок.",
|
||||
placeholder="oreol_vpn_bot",
|
||||
),
|
||||
"bot_start_image_enabled": BotConfigFieldSpec(
|
||||
key="bot_start_image_enabled",
|
||||
label="Стартовая картинка",
|
||||
section="brand",
|
||||
prompt="Введите `on` или `off`.",
|
||||
description="Включает или выключает бренд-картинку в панели и выдаче доступа.",
|
||||
placeholder="on",
|
||||
),
|
||||
"bot_start_image_path": BotConfigFieldSpec(
|
||||
key="bot_start_image_path",
|
||||
label="Путь к картинке",
|
||||
section="brand",
|
||||
prompt="Введите путь к изображению. Для очистки отправьте `-`.",
|
||||
description="Можно указать относительный путь от корня проекта.",
|
||||
placeholder="assets/main.png",
|
||||
),
|
||||
"payment_plans_raw": BotConfigFieldSpec(
|
||||
key="payment_plans_raw",
|
||||
label="Тарифы и цены",
|
||||
section="pricing",
|
||||
prompt="Введите тарифы в формате `30:250,180:600,365:1000`.",
|
||||
description="Каждая пара — это `дни:цена_в_рублях`.",
|
||||
placeholder="30:250,180:600,365:1000",
|
||||
),
|
||||
"payment_plan_traffic_limit_gb": BotConfigFieldSpec(
|
||||
key="payment_plan_traffic_limit_gb",
|
||||
label="Лимит трафика, GB",
|
||||
section="pricing",
|
||||
prompt="Введите лимит трафика в гигабайтах. `0` = безлимит.",
|
||||
description="Применяется к тарифам, создаваемым ботом.",
|
||||
placeholder="0",
|
||||
),
|
||||
"payment_plan_traffic_reset_period": BotConfigFieldSpec(
|
||||
key="payment_plan_traffic_reset_period",
|
||||
label="Стратегия сброса трафика",
|
||||
section="pricing",
|
||||
prompt="Введите стратегию, например `NO_RESET`.",
|
||||
description="Значение передаётся в Remnawave.",
|
||||
placeholder="NO_RESET",
|
||||
),
|
||||
"referral_enabled": BotConfigFieldSpec(
|
||||
key="referral_enabled",
|
||||
label="Реферальная система",
|
||||
section="referral",
|
||||
prompt="Введите `on` или `off`.",
|
||||
description="Включает или выключает ввод рефкода и начисление реферальных бонусов.",
|
||||
placeholder="on",
|
||||
),
|
||||
"referral_discount_percent": BotConfigFieldSpec(
|
||||
key="referral_discount_percent",
|
||||
label="Скидка по рефкоду, %",
|
||||
section="referral",
|
||||
prompt="Введите размер скидки в процентах от 0 до 100.",
|
||||
description="Применяется к покупателю перед оплатой.",
|
||||
placeholder="5",
|
||||
),
|
||||
"referral_bonus_days": BotConfigFieldSpec(
|
||||
key="referral_bonus_days",
|
||||
label="Бонус рефереру, дней",
|
||||
section="referral",
|
||||
prompt="Введите количество бонусных дней. `0` отключает бонус.",
|
||||
description="Начисляется после подтверждённой оплаты по рефкоду.",
|
||||
placeholder="7",
|
||||
),
|
||||
"bot_support_url": BotConfigFieldSpec(
|
||||
key="bot_support_url",
|
||||
label="Ссылка на поддержку",
|
||||
section="links",
|
||||
prompt="Введите URL поддержки. Для очистки отправьте `-`.",
|
||||
description="Кнопка быстрой связи с поддержкой в пользовательской панели.",
|
||||
placeholder="https://t.me/your_support_account",
|
||||
),
|
||||
"bot_terms_url": BotConfigFieldSpec(
|
||||
key="bot_terms_url",
|
||||
label="Ссылка на Terms",
|
||||
section="links",
|
||||
prompt="Введите URL правил использования. Для очистки отправьте `-`.",
|
||||
description="Открывается из раздела условий использования.",
|
||||
placeholder="https://example.com/terms",
|
||||
),
|
||||
"bot_support_ticket_link": BotConfigFieldSpec(
|
||||
key="bot_support_ticket_link",
|
||||
label="Ссылка на чат тикетов",
|
||||
section="links",
|
||||
prompt="Введите приватную ссылку на чат/топик тикетов. Для очистки отправьте `-`.",
|
||||
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
|
||||
placeholder="https://t.me/c/1234567890/2/3",
|
||||
),
|
||||
"payment_review_link": BotConfigFieldSpec(
|
||||
key="payment_review_link",
|
||||
label="Ссылка на очередь оплат",
|
||||
section="links",
|
||||
prompt="Введите приватную ссылку на чат/топик проверки оплат. Для очистки отправьте `-`.",
|
||||
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
|
||||
placeholder="https://t.me/c/1234567890/56/57",
|
||||
),
|
||||
"payment_transfer_text": BotConfigFieldSpec(
|
||||
key="payment_transfer_text",
|
||||
label="Инструкция по оплате",
|
||||
section="texts",
|
||||
prompt="Введите текст с реквизитами или инструкцией перевода.",
|
||||
description="Показывается пользователю после выбора тарифа.",
|
||||
placeholder="Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN",
|
||||
),
|
||||
"payment_support_text": BotConfigFieldSpec(
|
||||
key="payment_support_text",
|
||||
label="Текст поддержки по оплате",
|
||||
section="texts",
|
||||
prompt="Введите текст, который бот покажет при проблемах с оплатой.",
|
||||
description="Используется при отклонении оплаты и ошибках выдачи доступа.",
|
||||
placeholder="Если оплата прошла, но доступ не выдался, напишите в поддержку.",
|
||||
),
|
||||
"payment_internal_squad_uuids_raw": BotConfigFieldSpec(
|
||||
key="payment_internal_squad_uuids_raw",
|
||||
label="UUID внутренних групп",
|
||||
section="provisioning",
|
||||
prompt="Введите UUID внутренних групп через запятую.",
|
||||
description="Эти группы назначаются пользователю в Remnawave.",
|
||||
placeholder="11111111-1111-1111-1111-111111111111",
|
||||
),
|
||||
"payment_external_squad_uuid": BotConfigFieldSpec(
|
||||
key="payment_external_squad_uuid",
|
||||
label="UUID внешней группы",
|
||||
section="provisioning",
|
||||
prompt="Введите UUID внешней группы. Для очистки отправьте `-`.",
|
||||
description="Необязательный внешний squad для новых пользователей.",
|
||||
placeholder="11111111-1111-1111-1111-111111111111",
|
||||
),
|
||||
"payment_username_prefix": BotConfigFieldSpec(
|
||||
key="payment_username_prefix",
|
||||
label="Префикс логина",
|
||||
section="provisioning",
|
||||
prompt="Введите префикс логина для новых пользователей Remnawave.",
|
||||
description="Итоговый логин строится как `<prefix>-<telegram_id>-<name>`.",
|
||||
placeholder="Oreol",
|
||||
),
|
||||
"payment_user_tag": BotConfigFieldSpec(
|
||||
key="payment_user_tag",
|
||||
label="Tag пользователей",
|
||||
section="provisioning",
|
||||
prompt="Введите tag для создаваемых пользователей. Для очистки отправьте `-`.",
|
||||
description="Опциональный tag в Remnawave.",
|
||||
placeholder="BOT",
|
||||
),
|
||||
"notification_enabled": BotConfigFieldSpec(
|
||||
key="notification_enabled",
|
||||
label="Уведомления об истечении",
|
||||
section="notifications",
|
||||
prompt="Введите `on` или `off`.",
|
||||
description="Включает или выключает уведомления о скором истечении подписки.",
|
||||
placeholder="on",
|
||||
),
|
||||
"notification_days_before": BotConfigFieldSpec(
|
||||
key="notification_days_before",
|
||||
label="Дни до истечения",
|
||||
section="notifications",
|
||||
prompt="Введите дни через запятую, например `1,3`.",
|
||||
description="За сколько дней до истечения отправлять уведомления.",
|
||||
placeholder="1,3",
|
||||
),
|
||||
"notification_check_interval_hours": BotConfigFieldSpec(
|
||||
key="notification_check_interval_hours",
|
||||
label="Интервал проверки, ч",
|
||||
section="notifications",
|
||||
prompt="Введите интервал проверки в часах.",
|
||||
description="Как часто бот проверяет подписки на истечение.",
|
||||
placeholder="6",
|
||||
),
|
||||
}
|
||||
|
||||
SECTION_LABELS: dict[str, str] = {
|
||||
"brand": "Бренд",
|
||||
"pricing": "Тарифы",
|
||||
"referral": "Рефералка",
|
||||
"links": "Ссылки",
|
||||
"texts": "Тексты",
|
||||
"provisioning": "Provisioning",
|
||||
"notifications": "Уведомления",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Settings,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
super().__init__(settings)
|
||||
self._session_factory = session_factory
|
||||
|
||||
async def get_snapshot(self) -> BotConfigSnapshot:
|
||||
overrides = await self._get_overrides()
|
||||
|
||||
return BotConfigSnapshot(
|
||||
bot_brand_name=str(overrides.get("bot_brand_name", self._settings.bot_brand_name)),
|
||||
bot_public_username=str(overrides.get("bot_public_username", self._settings.bot_public_username)),
|
||||
bot_support_url=str(overrides.get("bot_support_url", self._settings.bot_support_url)),
|
||||
bot_terms_url=str(overrides.get("bot_terms_url", self._settings.bot_terms_url)),
|
||||
bot_support_ticket_link=str(overrides.get("bot_support_ticket_link", self._settings.bot_support_ticket_link)),
|
||||
payment_review_link=str(overrides.get("payment_review_link", self._settings.payment_review_link)),
|
||||
bot_start_image_enabled=self._parse_bool(
|
||||
overrides.get("bot_start_image_enabled"),
|
||||
default=self._settings.bot_start_image_enabled,
|
||||
),
|
||||
bot_start_image_path=str(overrides.get("bot_start_image_path", self._settings.bot_start_image_path)),
|
||||
referral_enabled=self._parse_bool(
|
||||
overrides.get("referral_enabled"),
|
||||
default=True,
|
||||
),
|
||||
referral_discount_percent=self._parse_int(
|
||||
overrides.get("referral_discount_percent"),
|
||||
default=self._settings.referral_discount_percent,
|
||||
),
|
||||
referral_bonus_days=self._parse_int(
|
||||
overrides.get("referral_bonus_days"),
|
||||
default=self._settings.referral_bonus_days,
|
||||
),
|
||||
payment_plans_raw=str(overrides.get("payment_plans_raw", self._settings.payment_plans_raw)),
|
||||
payment_transfer_text=str(overrides.get("payment_transfer_text", self._settings.payment_transfer_text)),
|
||||
payment_support_text=str(overrides.get("payment_support_text", self._settings.payment_support_text)),
|
||||
payment_plan_traffic_limit_gb=self._parse_int(
|
||||
overrides.get("payment_plan_traffic_limit_gb"),
|
||||
default=self._settings.payment_plan_traffic_limit_gb,
|
||||
),
|
||||
payment_plan_traffic_reset_period=str(
|
||||
overrides.get(
|
||||
"payment_plan_traffic_reset_period",
|
||||
self._settings.payment_plan_traffic_reset_period,
|
||||
)
|
||||
),
|
||||
payment_internal_squad_uuids_raw=str(
|
||||
overrides.get(
|
||||
"payment_internal_squad_uuids_raw",
|
||||
self._settings.payment_internal_squad_uuids_raw,
|
||||
)
|
||||
),
|
||||
payment_external_squad_uuid=str(
|
||||
overrides.get(
|
||||
"payment_external_squad_uuid",
|
||||
self._settings.payment_external_squad_uuid,
|
||||
)
|
||||
),
|
||||
payment_username_prefix=str(
|
||||
overrides.get("payment_username_prefix", self._settings.payment_username_prefix)
|
||||
),
|
||||
payment_user_tag=str(overrides.get("payment_user_tag", self._settings.payment_user_tag)),
|
||||
notification_enabled=self._parse_bool(
|
||||
overrides.get("notification_enabled"),
|
||||
default=True,
|
||||
),
|
||||
notification_days_before=str(overrides.get("notification_days_before", "1,3")),
|
||||
notification_check_interval_hours=self._parse_int(
|
||||
overrides.get("notification_check_interval_hours"),
|
||||
default=6,
|
||||
),
|
||||
fallback_support_ticket_chat_id=self._settings.support_ticket_chat_id,
|
||||
fallback_support_ticket_thread_id=self._settings.support_ticket_message_thread_id,
|
||||
fallback_payment_review_chat_id=self._settings.payment_review_chat_id,
|
||||
fallback_payment_review_thread_id=self._settings.payment_review_message_thread_id,
|
||||
)
|
||||
|
||||
async def update_setting(
|
||||
self,
|
||||
*,
|
||||
key: str,
|
||||
raw_value: str,
|
||||
updated_by_telegram_id: int,
|
||||
) -> str:
|
||||
spec = self.FIELD_SPECS.get(key)
|
||||
if spec is None:
|
||||
raise ValueError("Неизвестная настройка.")
|
||||
|
||||
normalized_value = self._normalize_value(key, raw_value)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
record = await session.scalar(
|
||||
select(BotSetting).where(BotSetting.key == key)
|
||||
)
|
||||
if record is None:
|
||||
record = BotSetting(key=key)
|
||||
session.add(record)
|
||||
|
||||
record.value = normalized_value
|
||||
record.updated_by_telegram_id = updated_by_telegram_id
|
||||
record.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
return normalized_value
|
||||
|
||||
@classmethod
|
||||
def get_spec(cls, key: str) -> BotConfigFieldSpec | None:
|
||||
return cls.FIELD_SPECS.get(key)
|
||||
|
||||
@classmethod
|
||||
def get_specs_for_section(cls, section: str) -> list[BotConfigFieldSpec]:
|
||||
return [spec for spec in cls.FIELD_SPECS.values() if spec.section == section]
|
||||
|
||||
@classmethod
|
||||
def get_sections(cls) -> list[tuple[str, str]]:
|
||||
return list(cls.SECTION_LABELS.items())
|
||||
|
||||
@classmethod
|
||||
def get_section_label(cls, section: str) -> str:
|
||||
return cls.SECTION_LABELS.get(section, section)
|
||||
|
||||
@classmethod
|
||||
def format_value(cls, *, snapshot: BotConfigSnapshot, key: str) -> str:
|
||||
value = getattr(snapshot, key, "")
|
||||
|
||||
if key in {"bot_start_image_enabled", "referral_enabled", "notification_enabled"}:
|
||||
return "on" if bool(value) else "off"
|
||||
|
||||
if key == "bot_public_username":
|
||||
normalized = snapshot.bot_public_username_normalized
|
||||
return f"@{normalized}" if normalized else "-"
|
||||
|
||||
if key in {
|
||||
"bot_support_url",
|
||||
"bot_terms_url",
|
||||
"bot_support_ticket_link",
|
||||
"payment_review_link",
|
||||
"bot_start_image_path",
|
||||
"payment_external_squad_uuid",
|
||||
"payment_user_tag",
|
||||
"payment_username_prefix",
|
||||
"payment_internal_squad_uuids_raw",
|
||||
"payment_plans_raw",
|
||||
"payment_plan_traffic_reset_period",
|
||||
"bot_brand_name",
|
||||
"notification_days_before",
|
||||
}:
|
||||
return cls._compact_text(str(value))
|
||||
|
||||
if key == "payment_plan_traffic_limit_gb":
|
||||
amount = int(value or 0)
|
||||
return "0 GB (unlimited)" if amount <= 0 else f"{amount} GB"
|
||||
|
||||
if key == "referral_discount_percent":
|
||||
return f"{max(int(value or 0), 0)}%"
|
||||
|
||||
if key == "referral_bonus_days":
|
||||
return f"{max(int(value or 0), 0)} days"
|
||||
|
||||
if key == "notification_check_interval_hours":
|
||||
return f"{max(int(value or 6), 1)} ч"
|
||||
|
||||
if key in {"payment_transfer_text", "payment_support_text"}:
|
||||
return cls._compact_text(str(value), max_length=120)
|
||||
|
||||
return cls._compact_text(str(value))
|
||||
|
||||
@staticmethod
|
||||
def _compact_text(raw_value: str, *, max_length: int = 80) -> str:
|
||||
normalized = " ".join(raw_value.split()).strip()
|
||||
if not normalized:
|
||||
return "-"
|
||||
if len(normalized) <= max_length:
|
||||
return normalized
|
||||
return f"{normalized[: max_length - 3]}..."
|
||||
|
||||
async def _get_overrides(self) -> dict[str, str]:
|
||||
async with self._session_factory() as session:
|
||||
rows = await session.execute(select(BotSetting))
|
||||
return {
|
||||
row.key: row.value
|
||||
for row in rows.scalars().all()
|
||||
}
|
||||
|
||||
def _normalize_value(self, key: str, raw_value: str) -> str:
|
||||
value = raw_value.strip()
|
||||
if key in {
|
||||
"bot_support_url",
|
||||
"bot_terms_url",
|
||||
"bot_support_ticket_link",
|
||||
"payment_review_link",
|
||||
"bot_start_image_path",
|
||||
"payment_external_squad_uuid",
|
||||
"payment_user_tag",
|
||||
"bot_public_username",
|
||||
} and value == "-":
|
||||
return ""
|
||||
|
||||
if key == "bot_brand_name":
|
||||
if not value:
|
||||
raise ValueError("Название бренда не может быть пустым.")
|
||||
return value[:80]
|
||||
|
||||
if key == "bot_public_username":
|
||||
return value.removeprefix("@")
|
||||
|
||||
if key == "bot_start_image_enabled":
|
||||
return "true" if self._parse_bool_token(value) else "false"
|
||||
|
||||
if key == "bot_start_image_path":
|
||||
return value
|
||||
|
||||
if key == "payment_plans_raw":
|
||||
plans = self._parse_payment_plans(value)
|
||||
return ",".join(f"{plan.days}:{plan.amount_rub}" for plan in plans)
|
||||
|
||||
if key == "payment_plan_traffic_limit_gb":
|
||||
amount = self._parse_non_negative_int(value, "Лимит трафика")
|
||||
return str(amount)
|
||||
|
||||
if key == "payment_plan_traffic_reset_period":
|
||||
if not value:
|
||||
raise ValueError("Стратегия сброса трафика не может быть пустой.")
|
||||
return value.upper()
|
||||
|
||||
if key == "referral_enabled":
|
||||
return "true" if self._parse_bool_token(value) else "false"
|
||||
|
||||
if key == "referral_discount_percent":
|
||||
amount = self._parse_non_negative_int(value, "Размер скидки")
|
||||
if amount > 100:
|
||||
raise ValueError("Размер скидки должен быть в диапазоне от 0 до 100.")
|
||||
return str(amount)
|
||||
|
||||
if key == "referral_bonus_days":
|
||||
amount = self._parse_non_negative_int(value, "Размер бонуса")
|
||||
return str(amount)
|
||||
|
||||
if key in {
|
||||
"bot_support_url",
|
||||
"bot_terms_url",
|
||||
"bot_support_ticket_link",
|
||||
"payment_review_link",
|
||||
}:
|
||||
return value
|
||||
|
||||
if key in {"payment_transfer_text", "payment_support_text"}:
|
||||
if not value:
|
||||
raise ValueError("Текст не может быть пустым.")
|
||||
return value[:4000]
|
||||
|
||||
if key == "payment_internal_squad_uuids_raw":
|
||||
normalized_parts = [part.strip() for part in value.split(",") if part.strip()]
|
||||
if not normalized_parts:
|
||||
raise ValueError("Нужно указать хотя бы один UUID внутренней группы.")
|
||||
return ",".join(normalized_parts)
|
||||
|
||||
if key == "payment_external_squad_uuid":
|
||||
return value
|
||||
|
||||
if key == "payment_username_prefix":
|
||||
if not value:
|
||||
raise ValueError("Префикс логина не может быть пустым.")
|
||||
return value[:32]
|
||||
|
||||
if key == "payment_user_tag":
|
||||
return value
|
||||
|
||||
if key == "notification_enabled":
|
||||
return "true" if self._parse_bool_token(value) else "false"
|
||||
|
||||
if key == "notification_days_before":
|
||||
parts = [p.strip() for p in value.split(",") if p.strip().isdigit() and int(p.strip()) > 0]
|
||||
if not parts:
|
||||
raise ValueError("Нужно указать хотя бы один день, например `1,3`.")
|
||||
return ",".join(parts)
|
||||
|
||||
if key == "notification_check_interval_hours":
|
||||
amount = self._parse_non_negative_int(value, "Интервал проверки")
|
||||
if amount < 1:
|
||||
raise ValueError("Интервал должен быть не менее 1 часа.")
|
||||
return str(amount)
|
||||
|
||||
raise ValueError("Настройка пока не поддерживается.")
|
||||
|
||||
@staticmethod
|
||||
def _parse_int(raw_value: object | None, *, default: int) -> int:
|
||||
try:
|
||||
return int(str(raw_value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _parse_bool(raw_value: object | None, *, default: bool) -> bool:
|
||||
if raw_value is None:
|
||||
return default
|
||||
try:
|
||||
return BotConfigService._parse_bool_token(str(raw_value))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _parse_bool_token(raw_value: str) -> bool:
|
||||
normalized = raw_value.strip().lower()
|
||||
if normalized in {"1", "true", "on", "yes", "y", "да", "вкл"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "off", "no", "n", "нет", "выкл"}:
|
||||
return False
|
||||
raise ValueError("Введите `on` или `off`.")
|
||||
|
||||
@staticmethod
|
||||
def _parse_non_negative_int(raw_value: str, label: str) -> int:
|
||||
if not raw_value.strip().isdigit():
|
||||
raise ValueError(f"{label} должен быть целым неотрицательным числом.")
|
||||
return int(raw_value.strip())
|
||||
|
||||
@staticmethod
|
||||
def _parse_payment_plans(raw_value: str) -> list[PaymentPlanSettings]:
|
||||
values: list[PaymentPlanSettings] = []
|
||||
for raw_part in raw_value.split(","):
|
||||
part = raw_part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
fragments = [fragment.strip() for fragment in part.split(":")]
|
||||
if len(fragments) != 2 or not fragments[0].isdigit() or not fragments[1].isdigit():
|
||||
raise ValueError(
|
||||
"Тарифы должны быть в формате `дни:цена`, например `30:250,180:600`."
|
||||
)
|
||||
|
||||
days = int(fragments[0])
|
||||
amount_rub = int(fragments[1])
|
||||
if days <= 0 or amount_rub <= 0:
|
||||
raise ValueError("У тарифов дни и цена должны быть больше нуля.")
|
||||
|
||||
values.append(
|
||||
PaymentPlanSettings(
|
||||
code=f"{days}d",
|
||||
days=days,
|
||||
amount_rub=amount_rub,
|
||||
)
|
||||
)
|
||||
|
||||
if not values:
|
||||
raise ValueError("Нужно указать хотя бы один тариф.")
|
||||
|
||||
return sorted(values, key=lambda item: item.days)
|
||||
239
app/services/notification_service.py
Normal file
239
app/services/notification_service.py
Normal file
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import SubscriptionNotification
|
||||
from app.services.bot_config_service import BotConfigSnapshot, StaticBotConfigService
|
||||
from app.services.sync_service import SyncService
|
||||
from app.utils.formatters import format_datetime_with_days_left
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NOTIFICATION_TYPE_EXPIRE_SOON = "EXPIRE_SOON"
|
||||
|
||||
|
||||
class SubscriptionNotificationService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
sync_service: SyncService,
|
||||
config_service: StaticBotConfigService,
|
||||
project_root: Path,
|
||||
) -> None:
|
||||
self._session_factory = session_factory
|
||||
self._sync_service = sync_service
|
||||
self._config_service = config_service
|
||||
self._project_root = project_root
|
||||
|
||||
async def _get_config(self) -> BotConfigSnapshot:
|
||||
return await self._config_service.get_snapshot()
|
||||
|
||||
def _resolve_image_path(self, config: BotConfigSnapshot) -> Path | None:
|
||||
if not config.bot_start_image_enabled:
|
||||
return None
|
||||
|
||||
raw_path = config.bot_start_image_path.strip()
|
||||
default_path = self._project_root / "assets" / "main.png"
|
||||
|
||||
if not raw_path:
|
||||
return default_path if default_path.is_file() else None
|
||||
|
||||
image_path = Path(raw_path).expanduser()
|
||||
if not image_path.is_absolute():
|
||||
image_path = self._project_root / image_path
|
||||
|
||||
return image_path if image_path.is_file() else None
|
||||
|
||||
async def _already_notified(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
remnawave_user_id: int,
|
||||
notification_type: str,
|
||||
expire_at_snapshot: datetime,
|
||||
) -> bool:
|
||||
existing = await session.scalar(
|
||||
select(SubscriptionNotification.id).where(
|
||||
SubscriptionNotification.remnawave_user_id == remnawave_user_id,
|
||||
SubscriptionNotification.notification_type == notification_type,
|
||||
SubscriptionNotification.expire_at_snapshot == expire_at_snapshot,
|
||||
)
|
||||
)
|
||||
return existing is not None
|
||||
|
||||
async def _record_notification(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
remnawave_user_id: int,
|
||||
telegram_id: int,
|
||||
notification_type: str,
|
||||
expire_at_snapshot: datetime,
|
||||
) -> None:
|
||||
session.add(
|
||||
SubscriptionNotification(
|
||||
remnawave_user_id=remnawave_user_id,
|
||||
telegram_id=telegram_id,
|
||||
notification_type=notification_type,
|
||||
expire_at_snapshot=expire_at_snapshot,
|
||||
sent_at=utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
async def check_and_notify(self, bot: Bot) -> int:
|
||||
config = await self._get_config()
|
||||
if not config.notification_enabled:
|
||||
return 0
|
||||
|
||||
days_list = config.notification_days_list
|
||||
if not days_list:
|
||||
return 0
|
||||
|
||||
total_sent = 0
|
||||
image_path = self._resolve_image_path(config)
|
||||
|
||||
for days_before in days_list:
|
||||
expiring = await self._sync_service.get_expiring_users(
|
||||
days_before=days_before,
|
||||
tolerance_hours=max(config.notification_check_interval_hours, 1),
|
||||
)
|
||||
|
||||
for rw_user_id, telegram_id, expire_at in expiring:
|
||||
if telegram_id is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
sent = await self._send_notification(
|
||||
bot,
|
||||
rw_user_id=rw_user_id,
|
||||
telegram_id=telegram_id,
|
||||
expire_at=expire_at,
|
||||
days_before=days_before,
|
||||
brand_name=config.bot_brand_name,
|
||||
image_path=image_path,
|
||||
)
|
||||
if sent:
|
||||
total_sent += 1
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to send expiration notification to telegram_id=%s",
|
||||
telegram_id,
|
||||
)
|
||||
|
||||
return total_sent
|
||||
|
||||
async def _send_notification(
|
||||
self,
|
||||
bot: Bot,
|
||||
*,
|
||||
rw_user_id: int,
|
||||
telegram_id: int,
|
||||
expire_at: datetime,
|
||||
days_before: int,
|
||||
brand_name: str,
|
||||
image_path: Path | None,
|
||||
) -> bool:
|
||||
notification_type = f"{NOTIFICATION_TYPE_EXPIRE_SOON}_{days_before}d"
|
||||
|
||||
async with self._session_factory() as session:
|
||||
if await self._already_notified(
|
||||
session,
|
||||
remnawave_user_id=rw_user_id,
|
||||
notification_type=notification_type,
|
||||
expire_at_snapshot=expire_at,
|
||||
):
|
||||
return False
|
||||
|
||||
text = self._build_notification_text(
|
||||
brand_name=brand_name,
|
||||
expire_at=expire_at,
|
||||
days_before=days_before,
|
||||
)
|
||||
|
||||
try:
|
||||
if image_path is not None:
|
||||
await bot.send_photo(
|
||||
chat_id=telegram_id,
|
||||
photo=FSInputFile(str(image_path)),
|
||||
caption=text,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=text,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Cannot send notification to telegram_id=%s (blocked or unavailable)",
|
||||
telegram_id,
|
||||
)
|
||||
return False
|
||||
|
||||
await self._record_notification(
|
||||
session,
|
||||
remnawave_user_id=rw_user_id,
|
||||
telegram_id=telegram_id,
|
||||
notification_type=notification_type,
|
||||
expire_at_snapshot=expire_at,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _build_notification_text(
|
||||
*,
|
||||
brand_name: str,
|
||||
expire_at: datetime,
|
||||
days_before: int,
|
||||
) -> str:
|
||||
import html as html_module
|
||||
|
||||
if days_before <= 1:
|
||||
urgency = "⚠️ Ваша подписка истекает <b>завтра</b>!"
|
||||
else:
|
||||
urgency = f"⏰ Ваша подписка истекает через <b>{days_before} дн.</b>"
|
||||
|
||||
lines = [
|
||||
f"<b>{html_module.escape(brand_name)}</b>",
|
||||
"",
|
||||
urgency,
|
||||
"",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"Дата истечения: <b>{format_datetime_with_days_left(expire_at)}</b>\n\n"
|
||||
"Чтобы продлить доступ, откройте бот и выберите тариф."
|
||||
"</blockquote>"
|
||||
),
|
||||
"",
|
||||
"<i>Нажмите /start чтобы открыть меню и продлить подписку.</i>",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
async def run_periodic(self, bot: Bot, *, default_interval_hours: int = 6) -> None:
|
||||
logger.info("Subscription notification periodic task started")
|
||||
while True:
|
||||
try:
|
||||
config = await self._get_config()
|
||||
interval_hours = max(config.notification_check_interval_hours, 1)
|
||||
except Exception:
|
||||
interval_hours = max(default_interval_hours, 1)
|
||||
|
||||
try:
|
||||
total = await self.check_and_notify(bot)
|
||||
if total > 0:
|
||||
logger.info("Sent %d subscription expiration notifications", total)
|
||||
except Exception:
|
||||
logger.exception("Error in subscription notification check")
|
||||
|
||||
await asyncio.sleep(interval_hours * 3600)
|
||||
983
app/services/payment_service.py
Normal file
983
app/services/payment_service.py
Normal file
@@ -0,0 +1,983 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import ceil
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.config import PaymentPlanSettings, Settings
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import PaymentOrder, ReferralBonus, ReferralInvite, TelegramUser
|
||||
from app.schemas.remnawave import RemnawaveUser
|
||||
from app.services.bot_config_service import BotConfigSnapshot, StaticBotConfigService
|
||||
from app.services.remnawave_client import RemnawaveApiClient, RemnawaveApiError
|
||||
from app.services.sync_service import SyncService
|
||||
|
||||
|
||||
PENDING_PAYMENT_STATUS = "PENDING"
|
||||
REVIEW_PAYMENT_STATUS = "REVIEW"
|
||||
PROCESSING_PAYMENT_STATUS = "PROCESSING"
|
||||
REJECTED_PAYMENT_STATUS = "REJECTED"
|
||||
FULFILLED_PAYMENT_STATUS = "FULFILLED"
|
||||
REFERRAL_BONUS_PENDING_STATUS = "PENDING"
|
||||
REFERRAL_BONUS_PROCESSING_STATUS = "PROCESSING"
|
||||
REFERRAL_BONUS_APPLIED_STATUS = "APPLIED"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PaymentPlan:
|
||||
code: str
|
||||
title: str
|
||||
description: str
|
||||
duration_days: int
|
||||
amount_rub: int
|
||||
discount_percent: int
|
||||
original_amount_rub: int
|
||||
applied_referral_code: str = ""
|
||||
traffic_limit_bytes: int = 0
|
||||
traffic_limit_strategy: str = "NO_RESET"
|
||||
internal_squad_uuids: list[str] | None = None
|
||||
external_squad_uuid: str | None = None
|
||||
|
||||
@property
|
||||
def is_ready(self) -> bool:
|
||||
return bool(self.internal_squad_uuids) and self.duration_days > 0 and self.amount_rub > 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CreatedPaymentOrder:
|
||||
order_uuid: str
|
||||
provision_username: str
|
||||
plan: PaymentPlan
|
||||
transfer_text: str
|
||||
extends_existing_access: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StoredPaymentOrder:
|
||||
order_uuid: str
|
||||
telegram_id: int
|
||||
plan_title: str
|
||||
plan_duration_days: int
|
||||
amount_rub: int
|
||||
provision_username: str
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class IssuedAccess:
|
||||
telegram_id: int
|
||||
username: str
|
||||
subscription_url: str
|
||||
expire_at: datetime
|
||||
short_uuid: str
|
||||
remnawave_user_uuid: str
|
||||
traffic_limit_bytes: int
|
||||
referral_bonus: ReferralBonusGrant | None = None
|
||||
is_renewal: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ReferralBonusGrant:
|
||||
inviter_telegram_id: int
|
||||
bonus_days: int
|
||||
status: str
|
||||
total_applied_days: int = 0
|
||||
expire_at: datetime | None = None
|
||||
applied_to_user_uuid: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppliedReferralBonus:
|
||||
telegram_id: int
|
||||
total_bonus_days: int
|
||||
expire_at: datetime
|
||||
applied_to_user_uuid: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PaymentAdminStats:
|
||||
pending_orders: int = 0
|
||||
review_orders: int = 0
|
||||
pending_referral_bonuses: int = 0
|
||||
|
||||
|
||||
class PaymentService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Settings,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
remnawave_client: RemnawaveApiClient,
|
||||
sync_service: SyncService,
|
||||
config_service: StaticBotConfigService | None = None,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._session_factory = session_factory
|
||||
self._remnawave_client = remnawave_client
|
||||
self._sync_service = sync_service
|
||||
self._config_service = config_service or StaticBotConfigService(settings)
|
||||
|
||||
async def _get_config(self) -> BotConfigSnapshot:
|
||||
return await self._config_service.get_snapshot()
|
||||
|
||||
async def get_available_plans(self, *, telegram_id: int) -> list[PaymentPlan]:
|
||||
config = await self._get_config()
|
||||
applied_referral_code = await self._get_applied_referral_code(
|
||||
telegram_id,
|
||||
config=config,
|
||||
)
|
||||
return [
|
||||
self._build_plan(
|
||||
plan,
|
||||
applied_referral_code=applied_referral_code,
|
||||
config=config,
|
||||
)
|
||||
for plan in config.payment_plans
|
||||
]
|
||||
|
||||
async def create_order(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
plan_code: str,
|
||||
) -> CreatedPaymentOrder:
|
||||
config = await self._get_config()
|
||||
applied_referral_code = await self._get_applied_referral_code(
|
||||
telegram_id,
|
||||
config=config,
|
||||
)
|
||||
plan = self._get_plan_by_code(
|
||||
plan_code,
|
||||
applied_referral_code=applied_referral_code,
|
||||
config=config,
|
||||
)
|
||||
if not plan.is_ready:
|
||||
raise ValueError(
|
||||
"Покупка не настроена. Проверьте PAYMENT_PLANS и PAYMENT_INTERNAL_SQUAD_UUIDS."
|
||||
)
|
||||
|
||||
existing_accesses = await self._sync_service.get_cached_users_for_telegram(telegram_id)
|
||||
order_uuid = str(uuid4())
|
||||
invoice_payload = f"manual:{plan.code}:{order_uuid}"
|
||||
provision_username = self._build_provision_username(
|
||||
config=config,
|
||||
order_uuid=order_uuid,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
user = await self._upsert_telegram_user(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
session.add(
|
||||
PaymentOrder(
|
||||
order_uuid=order_uuid,
|
||||
telegram_user_id=user.id,
|
||||
telegram_id=telegram_id,
|
||||
plan_code=plan.code,
|
||||
plan_title=plan.title,
|
||||
plan_duration_days=plan.duration_days,
|
||||
traffic_limit_bytes=plan.traffic_limit_bytes,
|
||||
traffic_limit_strategy=plan.traffic_limit_strategy,
|
||||
amount_stars=plan.amount_rub,
|
||||
currency="RUB",
|
||||
status=PENDING_PAYMENT_STATUS,
|
||||
invoice_payload=invoice_payload,
|
||||
provision_username=provision_username,
|
||||
error_message=applied_referral_code or None,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return CreatedPaymentOrder(
|
||||
order_uuid=order_uuid,
|
||||
provision_username=provision_username,
|
||||
plan=plan,
|
||||
transfer_text=config.payment_transfer_text.strip(),
|
||||
extends_existing_access=bool(existing_accesses),
|
||||
)
|
||||
|
||||
async def get_order_for_user(self, *, order_uuid: str, telegram_id: int) -> StoredPaymentOrder:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder).where(
|
||||
PaymentOrder.order_uuid == order_uuid,
|
||||
PaymentOrder.telegram_id == telegram_id,
|
||||
)
|
||||
)
|
||||
if order is None:
|
||||
raise ValueError("Заказ не найден.")
|
||||
|
||||
return StoredPaymentOrder(
|
||||
order_uuid=order.order_uuid,
|
||||
telegram_id=order.telegram_id,
|
||||
plan_title=order.plan_title,
|
||||
plan_duration_days=order.plan_duration_days,
|
||||
amount_rub=order.amount_stars,
|
||||
provision_username=order.provision_username,
|
||||
status=order.status,
|
||||
)
|
||||
|
||||
async def mark_order_under_review(self, *, order_uuid: str, telegram_id: int) -> StoredPaymentOrder:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder)
|
||||
.where(
|
||||
PaymentOrder.order_uuid == order_uuid,
|
||||
PaymentOrder.telegram_id == telegram_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if order is None:
|
||||
raise ValueError("Заказ не найден.")
|
||||
if order.status == REVIEW_PAYMENT_STATUS:
|
||||
raise ValueError("Чек уже отправлен на проверку.")
|
||||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже обрабатывается модератором.")
|
||||
if order.status == FULFILLED_PAYMENT_STATUS:
|
||||
raise ValueError("Этот заказ уже подтверждён.")
|
||||
if order.status == REJECTED_PAYMENT_STATUS:
|
||||
raise ValueError("Этот заказ уже отклонён. Создайте новый платёж.")
|
||||
if order.status != PENDING_PAYMENT_STATUS:
|
||||
raise ValueError("Некорректный статус заказа для отправки чека.")
|
||||
|
||||
order.status = REVIEW_PAYMENT_STATUS
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
return StoredPaymentOrder(
|
||||
order_uuid=order.order_uuid,
|
||||
telegram_id=order.telegram_id,
|
||||
plan_title=order.plan_title,
|
||||
plan_duration_days=order.plan_duration_days,
|
||||
amount_rub=order.amount_stars,
|
||||
provision_username=order.provision_username,
|
||||
status=order.status,
|
||||
)
|
||||
|
||||
async def revert_order_to_pending(self, *, order_uuid: str, telegram_id: int) -> None:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder)
|
||||
.where(
|
||||
PaymentOrder.order_uuid == order_uuid,
|
||||
PaymentOrder.telegram_id == telegram_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if order is None or order.status != REVIEW_PAYMENT_STATUS:
|
||||
return
|
||||
|
||||
order.status = PENDING_PAYMENT_STATUS
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def approve_order(self, *, order_uuid: str) -> IssuedAccess:
|
||||
order = await self._claim_order_for_approval(order_uuid)
|
||||
config = await self._get_config()
|
||||
is_renewal = False
|
||||
|
||||
try:
|
||||
remote_user: RemnawaveUser | None = None
|
||||
if order.remnawave_user_uuid:
|
||||
try:
|
||||
remote_user = await self._remnawave_client.get_user_by_uuid(
|
||||
order.remnawave_user_uuid
|
||||
)
|
||||
except RemnawaveApiError as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
remote_user = None
|
||||
|
||||
if remote_user is None:
|
||||
try:
|
||||
remote_user = await self._remnawave_client.get_user_by_username(
|
||||
order.provision_username
|
||||
)
|
||||
except RemnawaveApiError as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
|
||||
if remote_user is None:
|
||||
remote_users = await self._remnawave_client.get_users_by_telegram_id(
|
||||
order.telegram_id
|
||||
)
|
||||
remote_user = self._select_referral_bonus_target(remote_users)
|
||||
|
||||
if remote_user is None:
|
||||
remote_user = await self._create_remnawave_user(order, config=config)
|
||||
else:
|
||||
is_renewal = True
|
||||
remote_user = await self._extend_remnawave_user(
|
||||
order,
|
||||
remote_user,
|
||||
config=config,
|
||||
)
|
||||
except RemnawaveApiError as exc:
|
||||
await self._restore_review_after_failed_approval(order.order_uuid, exc.message)
|
||||
raise
|
||||
|
||||
await self._save_fulfilled_order(order.order_uuid, remote_user)
|
||||
own_bonus = None
|
||||
try:
|
||||
own_bonus = await self.apply_pending_referral_bonuses(
|
||||
telegram_id=order.telegram_id,
|
||||
refresh_cache=False,
|
||||
)
|
||||
except RemnawaveApiError as exc:
|
||||
logger.warning(
|
||||
"Could not apply pending referral bonus for telegram_id=%s: %s",
|
||||
order.telegram_id,
|
||||
exc.message,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._sync_service.refresh_cached_users_for_telegram(
|
||||
telegram_id=order.telegram_id,
|
||||
)
|
||||
except RemnawaveApiError:
|
||||
# Access is already issued at this point; cache can be refreshed later.
|
||||
pass
|
||||
|
||||
expire_at = remote_user.expire_at
|
||||
if own_bonus is not None and own_bonus.applied_to_user_uuid == str(remote_user.uuid):
|
||||
expire_at = own_bonus.expire_at
|
||||
|
||||
referral_bonus = await self._grant_referral_bonus(order, config=config)
|
||||
|
||||
return IssuedAccess(
|
||||
telegram_id=order.telegram_id,
|
||||
username=remote_user.username,
|
||||
subscription_url=remote_user.subscription_url,
|
||||
expire_at=expire_at,
|
||||
short_uuid=remote_user.short_uuid,
|
||||
remnawave_user_uuid=str(remote_user.uuid),
|
||||
traffic_limit_bytes=remote_user.traffic_limit_bytes,
|
||||
referral_bonus=referral_bonus,
|
||||
is_renewal=is_renewal,
|
||||
)
|
||||
|
||||
async def reject_order(self, *, order_uuid: str, reason: str | None = None) -> StoredPaymentOrder:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder)
|
||||
.where(PaymentOrder.order_uuid == order_uuid)
|
||||
.with_for_update()
|
||||
)
|
||||
if order is None:
|
||||
raise ValueError("Заказ не найден.")
|
||||
if order.status == FULFILLED_PAYMENT_STATUS:
|
||||
raise ValueError("Подтверждённый заказ нельзя отклонить.")
|
||||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже обрабатывается другим модератором.")
|
||||
if order.status == PENDING_PAYMENT_STATUS:
|
||||
raise ValueError("Чек ещё не отправлен на проверку.")
|
||||
if order.status == REJECTED_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже отклонён.")
|
||||
|
||||
order.status = REJECTED_PAYMENT_STATUS
|
||||
order.error_message = reason or "Платёж отклонён"
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
return StoredPaymentOrder(
|
||||
order_uuid=order.order_uuid,
|
||||
telegram_id=order.telegram_id,
|
||||
plan_title=order.plan_title,
|
||||
plan_duration_days=order.plan_duration_days,
|
||||
amount_rub=order.amount_stars,
|
||||
provision_username=order.provision_username,
|
||||
status=order.status,
|
||||
)
|
||||
|
||||
async def get_referral_code_for_order(self, *, order_uuid: str) -> str:
|
||||
order = await self._get_order(order_uuid)
|
||||
return order.error_message or ""
|
||||
|
||||
def build_transfer_text(self, *, order: CreatedPaymentOrder) -> str:
|
||||
lines = [
|
||||
"<b>Оплата тарифа</b>",
|
||||
"",
|
||||
f"<b>{html.escape(order.plan.title)}</b>",
|
||||
html.escape(order.plan.description),
|
||||
"",
|
||||
f"Сумма к переводу: <b>{order.plan.amount_rub} ₽</b>",
|
||||
]
|
||||
if order.plan.applied_referral_code:
|
||||
lines.append(
|
||||
f"Реферальный код: <code>{order.plan.applied_referral_code}</code> "
|
||||
f"(<b>-{order.plan.discount_percent}%</b>)"
|
||||
)
|
||||
if order.extends_existing_access:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
(
|
||||
"<blockquote>"
|
||||
"У вас уже есть доступ. После подтверждения оплаты бот автоматически "
|
||||
"продлит текущую подписку на выбранный срок."
|
||||
"</blockquote>"
|
||||
),
|
||||
]
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<b>Реквизиты</b>",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"{html.escape(order.transfer_text or 'Укажите реквизиты в PAYMENT_TRANSFER_TEXT.')}"
|
||||
"</blockquote>"
|
||||
),
|
||||
"",
|
||||
"После перевода нажмите кнопку ниже и отправьте чек следующим сообщением.",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_plan_by_code(
|
||||
self,
|
||||
plan_code: str,
|
||||
*,
|
||||
applied_referral_code: str,
|
||||
config: BotConfigSnapshot,
|
||||
) -> PaymentPlan:
|
||||
for plan in config.payment_plans:
|
||||
if plan.code == plan_code:
|
||||
return self._build_plan(
|
||||
plan,
|
||||
applied_referral_code=applied_referral_code,
|
||||
config=config,
|
||||
)
|
||||
|
||||
raise ValueError("Тариф не найден.")
|
||||
|
||||
def _build_plan(
|
||||
self,
|
||||
plan_settings: PaymentPlanSettings,
|
||||
*,
|
||||
applied_referral_code: str,
|
||||
config: BotConfigSnapshot,
|
||||
) -> PaymentPlan:
|
||||
discount_percent = max(config.referral_discount_percent, 0) if applied_referral_code else 0
|
||||
discounted_amount = self._apply_discount(
|
||||
amount_rub=plan_settings.amount_rub,
|
||||
discount_percent=discount_percent,
|
||||
)
|
||||
traffic_limit_bytes = max(config.payment_plan_traffic_limit_gb, 0) * 1024**3
|
||||
return PaymentPlan(
|
||||
code=plan_settings.code,
|
||||
title=f"{config.bot_brand_name} на {plan_settings.days} дней",
|
||||
description=f"Доступ к VPN на {plan_settings.days} дней",
|
||||
duration_days=plan_settings.days,
|
||||
amount_rub=discounted_amount,
|
||||
discount_percent=discount_percent,
|
||||
original_amount_rub=plan_settings.amount_rub,
|
||||
applied_referral_code=applied_referral_code,
|
||||
traffic_limit_bytes=traffic_limit_bytes,
|
||||
traffic_limit_strategy=config.payment_plan_traffic_reset_period.strip() or "NO_RESET",
|
||||
internal_squad_uuids=config.payment_internal_squad_uuids,
|
||||
external_squad_uuid=config.payment_external_squad_uuid_normalized,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _apply_discount(*, amount_rub: int, discount_percent: int) -> int:
|
||||
if discount_percent <= 0:
|
||||
return amount_rub
|
||||
return max(1, ceil(amount_rub * (100 - discount_percent) / 100))
|
||||
|
||||
async def _get_applied_referral_code(
|
||||
self,
|
||||
telegram_id: int,
|
||||
*,
|
||||
config: BotConfigSnapshot | None = None,
|
||||
) -> str:
|
||||
resolved_config = config or await self._get_config()
|
||||
if not resolved_config.referral_enabled:
|
||||
return ""
|
||||
summary = await self._sync_service.get_referral_summary(telegram_id)
|
||||
return summary.applied_referral_code.strip().upper()
|
||||
|
||||
async def apply_pending_referral_bonuses(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
refresh_cache: bool = True,
|
||||
) -> AppliedReferralBonus | None:
|
||||
config = await self._get_config()
|
||||
if not config.referral_enabled:
|
||||
return None
|
||||
|
||||
claimed_bonuses = await self._claim_pending_referral_bonuses(telegram_id)
|
||||
if not claimed_bonuses:
|
||||
return None
|
||||
|
||||
total_bonus_days = sum(max(bonus.bonus_days, 0) for bonus in claimed_bonuses)
|
||||
if total_bonus_days <= 0:
|
||||
await self._restore_claimed_referral_bonuses(
|
||||
[bonus.id for bonus in claimed_bonuses],
|
||||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
return None
|
||||
|
||||
remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)
|
||||
target_user = self._select_referral_bonus_target(remote_users)
|
||||
if target_user is None:
|
||||
await self._restore_claimed_referral_bonuses(
|
||||
[bonus.id for bonus in claimed_bonuses],
|
||||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
return None
|
||||
|
||||
current_expire_at = target_user.expire_at
|
||||
if current_expire_at.tzinfo is None:
|
||||
current_expire_at = current_expire_at.replace(tzinfo=timezone.utc)
|
||||
next_expire_at = max(current_expire_at, datetime.now(timezone.utc)) + timedelta(
|
||||
days=total_bonus_days
|
||||
)
|
||||
|
||||
try:
|
||||
updated_user = await self._remnawave_client.update_user(
|
||||
{
|
||||
"uuid": str(target_user.uuid),
|
||||
"expireAt": next_expire_at.isoformat().replace("+00:00", "Z"),
|
||||
"description": (
|
||||
f"Referral bonus applied by Telegram bot "
|
||||
f"({total_bonus_days} days)"
|
||||
),
|
||||
}
|
||||
)
|
||||
except RemnawaveApiError:
|
||||
await self._restore_claimed_referral_bonuses(
|
||||
[bonus.id for bonus in claimed_bonuses],
|
||||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
raise
|
||||
|
||||
await self._mark_referral_bonuses_applied(
|
||||
[bonus.id for bonus in claimed_bonuses],
|
||||
applied_to_user_uuid=str(updated_user.uuid),
|
||||
)
|
||||
|
||||
if refresh_cache:
|
||||
try:
|
||||
await self._sync_service.refresh_cached_users_for_telegram(
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
except RemnawaveApiError:
|
||||
pass
|
||||
|
||||
return AppliedReferralBonus(
|
||||
telegram_id=telegram_id,
|
||||
total_bonus_days=total_bonus_days,
|
||||
expire_at=updated_user.expire_at,
|
||||
applied_to_user_uuid=str(updated_user.uuid),
|
||||
)
|
||||
|
||||
async def get_admin_stats(self) -> PaymentAdminStats:
|
||||
async with self._session_factory() as session:
|
||||
pending_orders = await session.scalar(
|
||||
select(func.count(PaymentOrder.id)).where(
|
||||
PaymentOrder.status == PENDING_PAYMENT_STATUS
|
||||
)
|
||||
)
|
||||
review_orders = await session.scalar(
|
||||
select(func.count(PaymentOrder.id)).where(
|
||||
PaymentOrder.status == REVIEW_PAYMENT_STATUS
|
||||
)
|
||||
)
|
||||
pending_referral_bonuses = await session.scalar(
|
||||
select(func.count(ReferralBonus.id)).where(
|
||||
ReferralBonus.status == REFERRAL_BONUS_PENDING_STATUS
|
||||
)
|
||||
)
|
||||
|
||||
return PaymentAdminStats(
|
||||
pending_orders=int(pending_orders or 0),
|
||||
review_orders=int(review_orders or 0),
|
||||
pending_referral_bonuses=int(pending_referral_bonuses or 0),
|
||||
)
|
||||
|
||||
async def _get_order(self, order_uuid: str) -> PaymentOrder:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
|
||||
)
|
||||
if order is None:
|
||||
raise ValueError("Заказ не найден.")
|
||||
return order
|
||||
|
||||
async def _claim_order_for_approval(self, order_uuid: str) -> PaymentOrder:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder)
|
||||
.where(PaymentOrder.order_uuid == order_uuid)
|
||||
.with_for_update()
|
||||
)
|
||||
if order is None:
|
||||
raise ValueError("Заказ не найден.")
|
||||
if order.status == PENDING_PAYMENT_STATUS:
|
||||
raise ValueError("Чек ещё не отправлен на проверку.")
|
||||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже обрабатывается другим модератором.")
|
||||
if order.status == REJECTED_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже отклонён.")
|
||||
if order.status == FULFILLED_PAYMENT_STATUS:
|
||||
raise ValueError("Этот заказ уже подтверждён.")
|
||||
if order.status != REVIEW_PAYMENT_STATUS:
|
||||
raise ValueError("Некорректный статус заказа для подтверждения.")
|
||||
|
||||
order.status = PROCESSING_PAYMENT_STATUS
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
return order
|
||||
|
||||
async def _create_remnawave_user(
|
||||
self,
|
||||
order: PaymentOrder,
|
||||
*,
|
||||
config: BotConfigSnapshot,
|
||||
) -> RemnawaveUser:
|
||||
expire_at = datetime.now(timezone.utc) + timedelta(days=order.plan_duration_days)
|
||||
body: dict[str, object] = {
|
||||
"username": order.provision_username,
|
||||
"expireAt": expire_at.isoformat().replace("+00:00", "Z"),
|
||||
"trafficLimitBytes": order.traffic_limit_bytes,
|
||||
"trafficLimitStrategy": order.traffic_limit_strategy,
|
||||
"telegramId": order.telegram_id,
|
||||
"description": f"Created by Telegram bot manual payment {order.order_uuid}",
|
||||
"activeInternalSquads": config.payment_internal_squad_uuids,
|
||||
}
|
||||
if config.payment_user_tag_normalized:
|
||||
body["tag"] = config.payment_user_tag_normalized
|
||||
if config.payment_external_squad_uuid_normalized:
|
||||
body["externalSquadUuid"] = config.payment_external_squad_uuid_normalized
|
||||
return await self._remnawave_client.create_user(body)
|
||||
|
||||
async def _extend_remnawave_user(
|
||||
self,
|
||||
order: PaymentOrder,
|
||||
remote_user: RemnawaveUser,
|
||||
*,
|
||||
config: BotConfigSnapshot,
|
||||
) -> RemnawaveUser:
|
||||
current_expire_at = remote_user.expire_at
|
||||
if current_expire_at.tzinfo is None:
|
||||
current_expire_at = current_expire_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
base_expire_at = max(current_expire_at, datetime.now(timezone.utc))
|
||||
next_expire_at = base_expire_at + timedelta(days=order.plan_duration_days)
|
||||
|
||||
traffic_limit_bytes = order.traffic_limit_bytes
|
||||
if traffic_limit_bytes <= 0:
|
||||
traffic_limit_bytes = remote_user.traffic_limit_bytes
|
||||
|
||||
body: dict[str, object] = {
|
||||
"uuid": str(remote_user.uuid),
|
||||
"username": remote_user.username,
|
||||
"expireAt": next_expire_at.isoformat().replace("+00:00", "Z"),
|
||||
"trafficLimitBytes": traffic_limit_bytes,
|
||||
"trafficLimitStrategy": order.traffic_limit_strategy,
|
||||
"telegramId": order.telegram_id,
|
||||
"description": f"Updated by Telegram bot manual payment {order.order_uuid}",
|
||||
"activeInternalSquads": config.payment_internal_squad_uuids,
|
||||
}
|
||||
if config.payment_user_tag_normalized:
|
||||
body["tag"] = config.payment_user_tag_normalized
|
||||
if config.payment_external_squad_uuid_normalized:
|
||||
body["externalSquadUuid"] = config.payment_external_squad_uuid_normalized
|
||||
return await self._remnawave_client.update_user(body)
|
||||
|
||||
async def _save_fulfilled_order(self, order_uuid: str, remote_user: RemnawaveUser) -> None:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
|
||||
)
|
||||
if order is None:
|
||||
return
|
||||
order.status = FULFILLED_PAYMENT_STATUS
|
||||
order.remnawave_user_uuid = str(remote_user.uuid)
|
||||
order.subscription_url = remote_user.subscription_url
|
||||
order.error_message = None
|
||||
order.paid_at = utcnow()
|
||||
order.fulfilled_at = utcnow()
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def _save_order_error(self, order_uuid: str, error_message: str) -> None:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
|
||||
)
|
||||
if order is None:
|
||||
return
|
||||
order.error_message = error_message
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def _restore_review_after_failed_approval(
|
||||
self,
|
||||
order_uuid: str,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder)
|
||||
.where(PaymentOrder.order_uuid == order_uuid)
|
||||
.with_for_update()
|
||||
)
|
||||
if order is None:
|
||||
return
|
||||
|
||||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||||
order.status = REVIEW_PAYMENT_STATUS
|
||||
order.error_message = error_message
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def _grant_referral_bonus(
|
||||
self,
|
||||
order: PaymentOrder,
|
||||
*,
|
||||
config: BotConfigSnapshot,
|
||||
) -> ReferralBonusGrant | None:
|
||||
if not config.referral_enabled:
|
||||
return None
|
||||
|
||||
bonus_days = max(config.referral_bonus_days, 0)
|
||||
if bonus_days <= 0:
|
||||
return None
|
||||
|
||||
inviter_telegram_id = await self._get_inviter_telegram_id(order.telegram_id)
|
||||
if inviter_telegram_id is None or inviter_telegram_id == order.telegram_id:
|
||||
return None
|
||||
|
||||
await self._create_referral_bonus(
|
||||
inviter_telegram_id=inviter_telegram_id,
|
||||
invited_telegram_id=order.telegram_id,
|
||||
source_order_uuid=order.order_uuid,
|
||||
bonus_days=bonus_days,
|
||||
)
|
||||
|
||||
try:
|
||||
applied_bonus = await self.apply_pending_referral_bonuses(
|
||||
telegram_id=inviter_telegram_id,
|
||||
)
|
||||
except RemnawaveApiError as exc:
|
||||
logger.warning(
|
||||
"Could not apply referral bonus for inviter=%s after order=%s: %s",
|
||||
inviter_telegram_id,
|
||||
order.order_uuid,
|
||||
exc.message,
|
||||
)
|
||||
applied_bonus = None
|
||||
|
||||
if applied_bonus is None:
|
||||
return ReferralBonusGrant(
|
||||
inviter_telegram_id=inviter_telegram_id,
|
||||
bonus_days=bonus_days,
|
||||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
|
||||
return ReferralBonusGrant(
|
||||
inviter_telegram_id=inviter_telegram_id,
|
||||
bonus_days=bonus_days,
|
||||
status=REFERRAL_BONUS_APPLIED_STATUS,
|
||||
total_applied_days=applied_bonus.total_bonus_days,
|
||||
expire_at=applied_bonus.expire_at,
|
||||
applied_to_user_uuid=applied_bonus.applied_to_user_uuid,
|
||||
)
|
||||
|
||||
async def _get_inviter_telegram_id(self, invited_telegram_id: int) -> int | None:
|
||||
async with self._session_factory() as session:
|
||||
return await session.scalar(
|
||||
select(ReferralInvite.inviter_telegram_id).where(
|
||||
ReferralInvite.invited_telegram_id == invited_telegram_id
|
||||
)
|
||||
)
|
||||
|
||||
async def _create_referral_bonus(
|
||||
self,
|
||||
*,
|
||||
inviter_telegram_id: int,
|
||||
invited_telegram_id: int,
|
||||
source_order_uuid: str,
|
||||
bonus_days: int,
|
||||
) -> None:
|
||||
async with self._session_factory() as session:
|
||||
existing = await session.scalar(
|
||||
select(ReferralBonus).where(
|
||||
ReferralBonus.source_order_uuid == source_order_uuid
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
return
|
||||
|
||||
session.add(
|
||||
ReferralBonus(
|
||||
inviter_telegram_id=inviter_telegram_id,
|
||||
invited_telegram_id=invited_telegram_id,
|
||||
source_order_uuid=source_order_uuid,
|
||||
bonus_days=bonus_days,
|
||||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def _claim_pending_referral_bonuses(self, telegram_id: int) -> list[ReferralBonus]:
|
||||
async with self._session_factory() as session:
|
||||
bonuses = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(ReferralBonus)
|
||||
.where(
|
||||
ReferralBonus.inviter_telegram_id == telegram_id,
|
||||
ReferralBonus.status == REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
.order_by(ReferralBonus.created_at.asc(), ReferralBonus.id.asc())
|
||||
.with_for_update()
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if not bonuses:
|
||||
return []
|
||||
|
||||
for bonus in bonuses:
|
||||
bonus.status = REFERRAL_BONUS_PROCESSING_STATUS
|
||||
bonus.updated_at = utcnow()
|
||||
await session.commit()
|
||||
return bonuses
|
||||
|
||||
async def _restore_claimed_referral_bonuses(
|
||||
self,
|
||||
bonus_ids: list[int],
|
||||
*,
|
||||
status: str,
|
||||
) -> None:
|
||||
if not bonus_ids:
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
bonuses = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(ReferralBonus)
|
||||
.where(ReferralBonus.id.in_(bonus_ids))
|
||||
.with_for_update()
|
||||
)
|
||||
).all()
|
||||
)
|
||||
for bonus in bonuses:
|
||||
bonus.status = status
|
||||
bonus.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def _mark_referral_bonuses_applied(
|
||||
self,
|
||||
bonus_ids: list[int],
|
||||
*,
|
||||
applied_to_user_uuid: str,
|
||||
) -> None:
|
||||
if not bonus_ids:
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
bonuses = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(ReferralBonus)
|
||||
.where(ReferralBonus.id.in_(bonus_ids))
|
||||
.with_for_update()
|
||||
)
|
||||
).all()
|
||||
)
|
||||
for bonus in bonuses:
|
||||
bonus.status = REFERRAL_BONUS_APPLIED_STATUS
|
||||
bonus.applied_to_user_uuid = applied_to_user_uuid
|
||||
bonus.applied_at = utcnow()
|
||||
bonus.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
@staticmethod
|
||||
def _select_referral_bonus_target(
|
||||
remote_users: list[RemnawaveUser],
|
||||
) -> RemnawaveUser | None:
|
||||
if not remote_users:
|
||||
return None
|
||||
|
||||
def _sort_key(user: RemnawaveUser) -> tuple[int, datetime, str]:
|
||||
expire_at = user.expire_at
|
||||
if expire_at.tzinfo is None:
|
||||
expire_at = expire_at.replace(tzinfo=timezone.utc)
|
||||
return (0 if user.status.upper() == "ACTIVE" else 1, expire_at, user.username)
|
||||
|
||||
return sorted(remote_users, key=_sort_key)[0]
|
||||
|
||||
async def _upsert_telegram_user(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> TelegramUser:
|
||||
record = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
if record is None:
|
||||
record = TelegramUser(telegram_id=telegram_id)
|
||||
session.add(record)
|
||||
|
||||
record.username = username
|
||||
record.first_name = first_name
|
||||
record.last_name = last_name
|
||||
record.language_code = language_code
|
||||
record.is_admin = self._settings.is_admin(telegram_id)
|
||||
record.last_seen_at = utcnow()
|
||||
record.updated_at = utcnow()
|
||||
|
||||
await session.flush()
|
||||
return record
|
||||
|
||||
def _build_provision_username(
|
||||
self,
|
||||
*,
|
||||
config: BotConfigSnapshot,
|
||||
order_uuid: str,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
) -> str:
|
||||
base_name = username or first_name or "user"
|
||||
normalized_name = "".join(
|
||||
ch for ch in base_name if ch.isalnum() or ch in "_-"
|
||||
).strip("-_")
|
||||
if not normalized_name:
|
||||
normalized_name = "user"
|
||||
|
||||
prefix = config.payment_username_prefix_normalized or "Oreol"
|
||||
unique_suffix = order_uuid.replace("-", "")[:6]
|
||||
provision_username = f"{prefix}-{telegram_id}-{unique_suffix}-{normalized_name}"
|
||||
return provision_username[:36]
|
||||
224
app/services/remnawave_client.py
Normal file
224
app/services/remnawave_client.py
Normal file
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.schemas.remnawave import PaginatedUsers, RemnawaveUser, ResolvedUser, SubscriptionRequestHistory
|
||||
|
||||
|
||||
UUID_RE = re.compile(
|
||||
r"^[0-9a-fA-F]{8}-"
|
||||
r"[0-9a-fA-F]{4}-"
|
||||
r"[0-9a-fA-F]{4}-"
|
||||
r"[0-9a-fA-F]{4}-"
|
||||
r"[0-9a-fA-F]{12}$"
|
||||
)
|
||||
|
||||
|
||||
class RemnawaveApiError(RuntimeError):
|
||||
def __init__(self, *, status_code: int, message: str, payload: Any | None = None) -> None:
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.payload = payload
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class RemnawaveApiClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_token: str,
|
||||
caddy_api_key: str = "",
|
||||
timeout_seconds: float = 20.0,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
self._managed_client = client is None
|
||||
self._client = client or httpx.AsyncClient(
|
||||
base_url=self.normalize_base_url(base_url),
|
||||
timeout=timeout_seconds,
|
||||
headers=self._build_headers(
|
||||
base_url=base_url,
|
||||
api_token=api_token,
|
||||
caddy_api_key=caddy_api_key,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def normalize_base_url(base_url: str) -> str:
|
||||
normalized = base_url.strip().rstrip("/")
|
||||
if not normalized.endswith("/api"):
|
||||
normalized = f"{normalized}/api"
|
||||
return f"{normalized}/"
|
||||
|
||||
@staticmethod
|
||||
def unwrap_payload(payload: Any) -> Any:
|
||||
if isinstance(payload, Mapping) and "response" in payload:
|
||||
return payload["response"]
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _build_headers(
|
||||
*,
|
||||
base_url: str,
|
||||
api_token: str,
|
||||
caddy_api_key: str,
|
||||
) -> dict[str, str]:
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
if api_token:
|
||||
headers["Authorization"] = (
|
||||
api_token if api_token.startswith("Bearer ") else f"Bearer {api_token}"
|
||||
)
|
||||
if caddy_api_key:
|
||||
headers["X-Api-Key"] = caddy_api_key
|
||||
if base_url.startswith("http://"):
|
||||
headers["x-forwarded-proto"] = "https"
|
||||
headers["x-forwarded-for"] = "127.0.0.1"
|
||||
return headers
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._managed_client:
|
||||
await self._client.aclose()
|
||||
|
||||
async def get_users_by_telegram_id(self, telegram_id: int) -> list[RemnawaveUser]:
|
||||
payload = await self._request_json("GET", f"users/by-telegram-id/{telegram_id}")
|
||||
return [RemnawaveUser.model_validate(item) for item in payload]
|
||||
|
||||
async def get_user_by_uuid(self, user_uuid: str) -> RemnawaveUser:
|
||||
payload = await self._request_json("GET", f"users/{user_uuid}")
|
||||
return RemnawaveUser.model_validate(payload)
|
||||
|
||||
async def get_user_by_short_uuid(self, short_uuid: str) -> RemnawaveUser:
|
||||
payload = await self._request_json("GET", f"users/by-short-uuid/{short_uuid}")
|
||||
return RemnawaveUser.model_validate(payload)
|
||||
|
||||
async def get_user_by_username(self, username: str) -> RemnawaveUser:
|
||||
payload = await self._request_json("GET", f"users/by-username/{username}")
|
||||
return RemnawaveUser.model_validate(payload)
|
||||
|
||||
async def create_user(self, body: dict[str, Any]) -> RemnawaveUser:
|
||||
payload = await self._request_json("POST", "users", json=body)
|
||||
return RemnawaveUser.model_validate(payload)
|
||||
|
||||
async def update_user(self, body: dict[str, Any]) -> RemnawaveUser:
|
||||
payload = await self._request_json("PATCH", "users", json=body)
|
||||
return RemnawaveUser.model_validate(payload)
|
||||
|
||||
async def get_all_users(self, *, start: int = 0, size: int = 100) -> PaginatedUsers:
|
||||
payload = await self._request_json(
|
||||
"GET",
|
||||
"users",
|
||||
params={"start": start, "size": size},
|
||||
)
|
||||
return PaginatedUsers.model_validate(payload)
|
||||
|
||||
async def get_user_subscription_history(self, user_uuid: str) -> SubscriptionRequestHistory:
|
||||
payload = await self._request_json(
|
||||
"GET",
|
||||
f"users/{user_uuid}/subscription-request-history",
|
||||
)
|
||||
return SubscriptionRequestHistory.model_validate(payload)
|
||||
|
||||
async def resolve_user(self, identifier: str) -> ResolvedUser:
|
||||
body: dict[str, Any]
|
||||
|
||||
if UUID_RE.match(identifier):
|
||||
body = {"uuid": identifier}
|
||||
elif identifier.isdigit():
|
||||
body = {"id": int(identifier)}
|
||||
else:
|
||||
try:
|
||||
payload = await self._request_json(
|
||||
"POST",
|
||||
"users/resolve",
|
||||
json={"shortUuid": identifier},
|
||||
)
|
||||
return ResolvedUser.model_validate(payload)
|
||||
except RemnawaveApiError as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
body = {"username": identifier}
|
||||
|
||||
payload = await self._request_json("POST", "users/resolve", json=body)
|
||||
return ResolvedUser.model_validate(payload)
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
json: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
response = await self._client.request(
|
||||
method=method,
|
||||
url=path.lstrip("/"),
|
||||
params=params,
|
||||
json=json,
|
||||
)
|
||||
|
||||
if response.is_error:
|
||||
raise self._build_error(response)
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
|
||||
return self.unwrap_payload(payload)
|
||||
|
||||
def _build_error(self, response: httpx.Response) -> RemnawaveApiError:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
payload = None
|
||||
|
||||
message = "Unknown Remnawave API error"
|
||||
if isinstance(payload, Mapping):
|
||||
for key in ("message", "error", "code"):
|
||||
candidate = payload.get(key)
|
||||
if candidate:
|
||||
message = str(candidate)
|
||||
break
|
||||
|
||||
error_code = payload.get("errorCode")
|
||||
if error_code:
|
||||
message = f"{message} [{error_code}]"
|
||||
|
||||
error_items = payload.get("errors")
|
||||
if isinstance(error_items, list):
|
||||
details: list[str] = []
|
||||
for item in error_items:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
|
||||
path_value = item.get("path")
|
||||
if isinstance(path_value, list):
|
||||
path_text = ".".join(str(part) for part in path_value if part is not None)
|
||||
elif path_value is not None:
|
||||
path_text = str(path_value)
|
||||
else:
|
||||
path_text = ""
|
||||
|
||||
detail_message = str(item.get("message") or item.get("code") or "").strip()
|
||||
if not detail_message:
|
||||
continue
|
||||
|
||||
if path_text:
|
||||
details.append(f"{path_text}: {detail_message}")
|
||||
else:
|
||||
details.append(detail_message)
|
||||
|
||||
if details:
|
||||
message = f"{message} | {'; '.join(details)}"
|
||||
elif response.text:
|
||||
message = response.text
|
||||
|
||||
return RemnawaveApiError(
|
||||
status_code=response.status_code,
|
||||
message=message,
|
||||
payload=payload,
|
||||
)
|
||||
72
app/services/support_ticket_service.py
Normal file
72
app/services/support_ticket_service.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import SupportTicket
|
||||
|
||||
|
||||
class SupportTicketService:
|
||||
def __init__(self, *, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
async def create_ticket(
|
||||
self,
|
||||
*,
|
||||
public_id: str,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
display_name: str,
|
||||
user_message: str,
|
||||
support_chat_id: int,
|
||||
support_thread_id: int | None,
|
||||
support_message_id: int,
|
||||
) -> SupportTicket:
|
||||
async with self._session_factory() as session:
|
||||
ticket = SupportTicket(
|
||||
public_id=public_id,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
user_message=user_message,
|
||||
support_chat_id=support_chat_id,
|
||||
support_thread_id=support_thread_id,
|
||||
support_message_id=support_message_id,
|
||||
status="OPEN",
|
||||
)
|
||||
session.add(ticket)
|
||||
await session.commit()
|
||||
await session.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
async def get_ticket_by_support_message(
|
||||
self,
|
||||
*,
|
||||
support_chat_id: int,
|
||||
support_message_id: int,
|
||||
) -> SupportTicket | None:
|
||||
async with self._session_factory() as session:
|
||||
return await session.scalar(
|
||||
select(SupportTicket).where(
|
||||
SupportTicket.support_chat_id == support_chat_id,
|
||||
SupportTicket.support_message_id == support_message_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def mark_answered(self, *, ticket_id: int) -> None:
|
||||
async with self._session_factory() as session:
|
||||
ticket = await session.get(SupportTicket, ticket_id)
|
||||
if ticket is None:
|
||||
return
|
||||
|
||||
ticket.status = "ANSWERED"
|
||||
ticket.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def get_open_tickets_count(self) -> int:
|
||||
async with self._session_factory() as session:
|
||||
total = await session.scalar(
|
||||
select(func.count(SupportTicket.id)).where(SupportTicket.status == "OPEN")
|
||||
)
|
||||
return int(total or 0)
|
||||
811
app/services/sync_service.py
Normal file
811
app/services/sync_service.py
Normal file
@@ -0,0 +1,811 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import (
|
||||
ReferralCode,
|
||||
ReferralInvite,
|
||||
InternalSquad,
|
||||
RemnawaveUser,
|
||||
RemnawaveUserInternalSquad,
|
||||
SubscriptionRequestLog,
|
||||
TelegramUser,
|
||||
)
|
||||
from app.schemas.remnawave import RemnawaveUser as RemoteRemnawaveUser
|
||||
from app.schemas.remnawave import SubscriptionRequestHistory
|
||||
from app.services.remnawave_client import RemnawaveApiClient
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CachedUserView:
|
||||
record: RemnawaveUser
|
||||
internal_squads: list[str] = field(default_factory=list)
|
||||
recent_requests: list[SubscriptionRequestLog] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ReferralSummary:
|
||||
total_invited: int = 0
|
||||
referral_code: str = ""
|
||||
applied_referral_code: str = ""
|
||||
recent_names: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdminStats:
|
||||
total_telegram_users: int = 0
|
||||
total_cached_users: int = 0
|
||||
active_cached_users: int = 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdminTelegramUserListItem:
|
||||
telegram_id: int
|
||||
username: str | None
|
||||
first_name: str | None
|
||||
last_name: str | None
|
||||
language_code: str | None
|
||||
is_admin: bool
|
||||
is_blocked: bool
|
||||
last_seen_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
value = " ".join(part for part in [self.first_name, self.last_name] if part).strip()
|
||||
return value or self.username or str(self.telegram_id)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdminTelegramUsersPage:
|
||||
items: list[AdminTelegramUserListItem] = field(default_factory=list)
|
||||
page: int = 1
|
||||
page_size: int = 8
|
||||
total_items: int = 0
|
||||
total_pages: int = 1
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdminTelegramUserProfile:
|
||||
user: TelegramUser
|
||||
referral_code: str = ""
|
||||
applied_referral_code: str = ""
|
||||
invited_count: int = 0
|
||||
recent_invited_names: list[str] = field(default_factory=list)
|
||||
accesses: list[CachedUserView] = field(default_factory=list)
|
||||
|
||||
|
||||
class SyncService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Settings,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
remnawave_client: RemnawaveApiClient,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._session_factory = session_factory
|
||||
self._remnawave_client = remnawave_client
|
||||
|
||||
async def is_user_blocked(self, telegram_id: int) -> bool:
|
||||
async with self._session_factory() as session:
|
||||
is_blocked = await session.scalar(
|
||||
select(TelegramUser.is_blocked).where(
|
||||
TelegramUser.telegram_id == telegram_id
|
||||
)
|
||||
)
|
||||
return bool(is_blocked)
|
||||
|
||||
async def block_telegram_user(self, telegram_id: int) -> None:
|
||||
async with self._session_factory() as session:
|
||||
user = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
if user is not None:
|
||||
user.is_blocked = True
|
||||
user.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def unblock_telegram_user(self, telegram_id: int) -> None:
|
||||
async with self._session_factory() as session:
|
||||
user = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
if user is not None:
|
||||
user.is_blocked = False
|
||||
user.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def get_expiring_users(
|
||||
self,
|
||||
*,
|
||||
days_before: int,
|
||||
tolerance_hours: int = 6,
|
||||
) -> list[tuple[int, int, datetime]]:
|
||||
"""Return (remnawave_user.id, telegram_id, expire_at) for users expiring within window."""
|
||||
from datetime import timedelta
|
||||
|
||||
now = utcnow()
|
||||
window_start = now + timedelta(days=days_before) - timedelta(hours=tolerance_hours)
|
||||
window_end = now + timedelta(days=days_before) + timedelta(hours=tolerance_hours)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
rows = await session.execute(
|
||||
select(
|
||||
RemnawaveUser.id,
|
||||
RemnawaveUser.telegram_id,
|
||||
RemnawaveUser.expire_at,
|
||||
)
|
||||
.where(
|
||||
RemnawaveUser.status == "ACTIVE",
|
||||
RemnawaveUser.telegram_id.isnot(None),
|
||||
RemnawaveUser.expire_at >= window_start,
|
||||
RemnawaveUser.expire_at <= window_end,
|
||||
)
|
||||
)
|
||||
return [(row[0], row[1], row[2]) for row in rows.all()]
|
||||
|
||||
async def register_telegram_user(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> None:
|
||||
async with self._session_factory() as session:
|
||||
await self._upsert_telegram_user(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
|
||||
await session.commit()
|
||||
|
||||
async def apply_referral_code(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
referral_code: str,
|
||||
) -> str:
|
||||
async with self._session_factory() as session:
|
||||
invited = await self._upsert_telegram_user(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
|
||||
|
||||
existing = await session.scalar(
|
||||
select(ReferralInvite).where(
|
||||
ReferralInvite.invited_telegram_id == telegram_id
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
raise ValueError("Реферальный код уже применен к вашему аккаунту.")
|
||||
|
||||
inviter_telegram_id = await self._find_inviter_by_referral_code(session, referral_code)
|
||||
if inviter_telegram_id is None:
|
||||
raise ValueError("Реферальный код не найден.")
|
||||
if inviter_telegram_id == telegram_id:
|
||||
raise ValueError("Нельзя использовать собственный реферальный код.")
|
||||
|
||||
invite_created = await self._upsert_referral_invite(
|
||||
session,
|
||||
invited=invited,
|
||||
referred_by_telegram_id=inviter_telegram_id,
|
||||
)
|
||||
if not invite_created:
|
||||
raise ValueError("Не удалось применить реферальный код.")
|
||||
|
||||
await session.commit()
|
||||
return referral_code.strip().upper()
|
||||
|
||||
async def get_referral_summary(self, telegram_id: int) -> ReferralSummary:
|
||||
async with self._session_factory() as session:
|
||||
referral_code = await self._get_or_create_referral_code(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
await session.commit()
|
||||
total_invited = await session.scalar(
|
||||
select(func.count(ReferralInvite.id)).where(
|
||||
ReferralInvite.inviter_telegram_id == telegram_id
|
||||
)
|
||||
)
|
||||
recent_rows = await session.execute(
|
||||
select(ReferralInvite.invited_display_name)
|
||||
.where(ReferralInvite.inviter_telegram_id == telegram_id)
|
||||
.order_by(ReferralInvite.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_names = list(recent_rows.scalars().all())
|
||||
applied_referral_code = await session.scalar(
|
||||
select(ReferralCode.code)
|
||||
.join(
|
||||
ReferralInvite,
|
||||
ReferralInvite.inviter_telegram_id == ReferralCode.telegram_id,
|
||||
)
|
||||
.where(ReferralInvite.invited_telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
return ReferralSummary(
|
||||
total_invited=int(total_invited or 0),
|
||||
referral_code=referral_code,
|
||||
applied_referral_code=applied_referral_code or "",
|
||||
recent_names=recent_names,
|
||||
)
|
||||
|
||||
async def get_admin_stats(self) -> AdminStats:
|
||||
async with self._session_factory() as session:
|
||||
total_telegram_users = await session.scalar(
|
||||
select(func.count(TelegramUser.id))
|
||||
)
|
||||
total_cached_users = await session.scalar(
|
||||
select(func.count(RemnawaveUser.id))
|
||||
)
|
||||
active_cached_users = await session.scalar(
|
||||
select(func.count(RemnawaveUser.id)).where(RemnawaveUser.status == "ACTIVE")
|
||||
)
|
||||
|
||||
return AdminStats(
|
||||
total_telegram_users=int(total_telegram_users or 0),
|
||||
total_cached_users=int(total_cached_users or 0),
|
||||
active_cached_users=int(active_cached_users or 0),
|
||||
)
|
||||
|
||||
async def get_admin_telegram_users_page(
|
||||
self,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int = 8,
|
||||
) -> AdminTelegramUsersPage:
|
||||
resolved_page_size = max(1, min(page_size, 20))
|
||||
|
||||
async with self._session_factory() as session:
|
||||
total_items = int(
|
||||
await session.scalar(select(func.count(TelegramUser.id))) or 0
|
||||
)
|
||||
total_pages = max(1, math.ceil(total_items / resolved_page_size)) if total_items else 1
|
||||
resolved_page = min(max(page, 1), total_pages)
|
||||
|
||||
rows = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(TelegramUser)
|
||||
.order_by(
|
||||
TelegramUser.last_seen_at.desc(),
|
||||
TelegramUser.created_at.desc(),
|
||||
TelegramUser.id.desc(),
|
||||
)
|
||||
.offset((resolved_page - 1) * resolved_page_size)
|
||||
.limit(resolved_page_size)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
return AdminTelegramUsersPage(
|
||||
items=[
|
||||
AdminTelegramUserListItem(
|
||||
telegram_id=row.telegram_id,
|
||||
username=row.username,
|
||||
first_name=row.first_name,
|
||||
last_name=row.last_name,
|
||||
language_code=row.language_code,
|
||||
is_admin=row.is_admin,
|
||||
is_blocked=row.is_blocked,
|
||||
last_seen_at=row.last_seen_at,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
for row in rows
|
||||
],
|
||||
page=resolved_page,
|
||||
page_size=resolved_page_size,
|
||||
total_items=total_items,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
async def get_admin_telegram_user_profile(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
) -> AdminTelegramUserProfile | None:
|
||||
async with self._session_factory() as session:
|
||||
user = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
referral_code = await session.scalar(
|
||||
select(ReferralCode.code).where(ReferralCode.telegram_id == telegram_id)
|
||||
)
|
||||
invited_count = await session.scalar(
|
||||
select(func.count(ReferralInvite.id)).where(
|
||||
ReferralInvite.inviter_telegram_id == telegram_id
|
||||
)
|
||||
)
|
||||
recent_rows = await session.execute(
|
||||
select(ReferralInvite.invited_display_name)
|
||||
.where(ReferralInvite.inviter_telegram_id == telegram_id)
|
||||
.order_by(ReferralInvite.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_invited_names = list(recent_rows.scalars().all())
|
||||
applied_referral_code = await session.scalar(
|
||||
select(ReferralCode.code)
|
||||
.join(
|
||||
ReferralInvite,
|
||||
ReferralInvite.inviter_telegram_id == ReferralCode.telegram_id,
|
||||
)
|
||||
.where(ReferralInvite.invited_telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
accesses = await self.get_cached_users_for_telegram(telegram_id)
|
||||
|
||||
return AdminTelegramUserProfile(
|
||||
user=user,
|
||||
referral_code=referral_code or "",
|
||||
applied_referral_code=applied_referral_code or "",
|
||||
invited_count=int(invited_count or 0),
|
||||
recent_invited_names=recent_invited_names,
|
||||
accesses=accesses,
|
||||
)
|
||||
|
||||
async def sync_users_for_telegram(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> list[CachedUserView]:
|
||||
remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
owner = await self._upsert_telegram_user(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
await self._sync_remote_users_for_telegram(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
remote_users=remote_users,
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return await self.get_cached_users_for_telegram(telegram_id)
|
||||
|
||||
async def refresh_cached_users_for_telegram(self, *, telegram_id: int) -> list[CachedUserView]:
|
||||
remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
owner = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
await self._sync_remote_users_for_telegram(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
remote_users=remote_users,
|
||||
owner=owner,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return await self.get_cached_users_for_telegram(telegram_id)
|
||||
|
||||
async def link_user_by_short_uuid(
|
||||
self,
|
||||
*,
|
||||
short_uuid: str,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> CachedUserView:
|
||||
remote_user = await self._remnawave_client.get_user_by_short_uuid(short_uuid)
|
||||
|
||||
if remote_user.telegram_id and remote_user.telegram_id != telegram_id:
|
||||
raise ValueError("Этот аккаунт уже привязан к другому Telegram ID.")
|
||||
|
||||
updated_user = await self._remnawave_client.update_user(
|
||||
{
|
||||
"uuid": str(remote_user.uuid),
|
||||
"telegramId": telegram_id,
|
||||
}
|
||||
)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
owner = await self._upsert_telegram_user(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
await self._upsert_remnawave_user(session, updated_user, owner)
|
||||
if self._settings.sync_subscription_history:
|
||||
history = await self._remnawave_client.get_user_subscription_history(
|
||||
str(updated_user.uuid)
|
||||
)
|
||||
await self._upsert_history(session, str(updated_user.uuid), history)
|
||||
await session.commit()
|
||||
|
||||
user = await self.get_cached_user_by_uuid(str(updated_user.uuid), include_history=True)
|
||||
if user is None:
|
||||
raise ValueError("Не удалось получить локальный кэш пользователя после привязки.")
|
||||
return user
|
||||
|
||||
async def sync_user_by_identifier(
|
||||
self,
|
||||
*,
|
||||
identifier: str,
|
||||
include_history: bool = True,
|
||||
) -> CachedUserView:
|
||||
resolved = await self._remnawave_client.resolve_user(identifier)
|
||||
remote_user = await self._remnawave_client.get_user_by_uuid(str(resolved.uuid))
|
||||
history = None
|
||||
|
||||
if include_history and self._settings.sync_subscription_history:
|
||||
history = await self._remnawave_client.get_user_subscription_history(str(resolved.uuid))
|
||||
|
||||
async with self._session_factory() as session:
|
||||
await self._upsert_remnawave_user(session, remote_user, owner=None)
|
||||
if history is not None:
|
||||
await self._upsert_history(session, str(resolved.uuid), history)
|
||||
await session.commit()
|
||||
|
||||
user = await self.get_cached_user_by_uuid(str(resolved.uuid), include_history=include_history)
|
||||
if user is None:
|
||||
raise ValueError("Не удалось получить локальный кэш пользователя после синхронизации.")
|
||||
return user
|
||||
|
||||
async def sync_all_users(self, batch_size: int) -> int:
|
||||
start = 0
|
||||
total_synced = 0
|
||||
|
||||
while True:
|
||||
page = await self._remnawave_client.get_all_users(start=start, size=batch_size)
|
||||
if not page.users:
|
||||
break
|
||||
|
||||
async with self._session_factory() as session:
|
||||
for remote_user in page.users:
|
||||
await self._upsert_remnawave_user(session, remote_user, owner=None)
|
||||
total_synced += 1
|
||||
await session.commit()
|
||||
|
||||
start += len(page.users)
|
||||
if start >= page.total:
|
||||
break
|
||||
|
||||
return total_synced
|
||||
|
||||
async def get_cached_users_for_telegram(self, telegram_id: int) -> list[CachedUserView]:
|
||||
async with self._session_factory() as session:
|
||||
rows = await session.execute(
|
||||
select(RemnawaveUser, InternalSquad.name)
|
||||
.outerjoin(
|
||||
RemnawaveUserInternalSquad,
|
||||
RemnawaveUserInternalSquad.user_id == RemnawaveUser.id,
|
||||
)
|
||||
.outerjoin(
|
||||
InternalSquad,
|
||||
InternalSquad.uuid == RemnawaveUserInternalSquad.squad_uuid,
|
||||
)
|
||||
.where(RemnawaveUser.telegram_id == telegram_id)
|
||||
.order_by(RemnawaveUser.username.asc())
|
||||
)
|
||||
return self._build_views(rows.all())
|
||||
|
||||
async def get_cached_user_by_uuid(
|
||||
self,
|
||||
user_uuid: str,
|
||||
*,
|
||||
include_history: bool = False,
|
||||
) -> CachedUserView | None:
|
||||
async with self._session_factory() as session:
|
||||
rows = await session.execute(
|
||||
select(RemnawaveUser, InternalSquad.name)
|
||||
.outerjoin(
|
||||
RemnawaveUserInternalSquad,
|
||||
RemnawaveUserInternalSquad.user_id == RemnawaveUser.id,
|
||||
)
|
||||
.outerjoin(
|
||||
InternalSquad,
|
||||
InternalSquad.uuid == RemnawaveUserInternalSquad.squad_uuid,
|
||||
)
|
||||
.where(RemnawaveUser.rw_uuid == user_uuid)
|
||||
)
|
||||
views = self._build_views(rows.all())
|
||||
if not views:
|
||||
return None
|
||||
|
||||
view = views[0]
|
||||
if include_history:
|
||||
history_rows = await session.execute(
|
||||
select(SubscriptionRequestLog)
|
||||
.where(SubscriptionRequestLog.user_uuid == user_uuid)
|
||||
.order_by(SubscriptionRequestLog.request_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
view.recent_requests = list(history_rows.scalars().all())
|
||||
return view
|
||||
|
||||
async def _upsert_telegram_user(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> TelegramUser:
|
||||
record = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
if record is None:
|
||||
record = TelegramUser(telegram_id=telegram_id)
|
||||
session.add(record)
|
||||
|
||||
record.username = username
|
||||
record.first_name = first_name
|
||||
record.last_name = last_name
|
||||
record.language_code = language_code
|
||||
record.is_admin = self._settings.is_admin(telegram_id)
|
||||
record.last_seen_at = utcnow()
|
||||
record.updated_at = utcnow()
|
||||
|
||||
await session.flush()
|
||||
return record
|
||||
|
||||
async def _sync_remote_users_for_telegram(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
telegram_id: int,
|
||||
remote_users: list[RemoteRemnawaveUser],
|
||||
owner: TelegramUser | None,
|
||||
) -> None:
|
||||
active_uuids = {str(remote_user.uuid) for remote_user in remote_users}
|
||||
for remote_user in remote_users:
|
||||
await self._upsert_remnawave_user(session, remote_user, owner)
|
||||
|
||||
filters = [RemnawaveUser.telegram_id == telegram_id]
|
||||
if owner is not None:
|
||||
filters.append(RemnawaveUser.owner_telegram_user_id == owner.id)
|
||||
|
||||
stale_records = await session.scalars(
|
||||
select(RemnawaveUser).where(or_(*filters))
|
||||
)
|
||||
for record in stale_records:
|
||||
if record.rw_uuid in active_uuids:
|
||||
continue
|
||||
|
||||
if record.telegram_id == telegram_id:
|
||||
record.telegram_id = None
|
||||
if owner is not None and record.owner_telegram_user_id == owner.id:
|
||||
record.owner_telegram_user_id = None
|
||||
record.synced_at = utcnow()
|
||||
|
||||
async def _upsert_remnawave_user(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
remote_user: RemoteRemnawaveUser,
|
||||
owner: TelegramUser | None,
|
||||
) -> RemnawaveUser:
|
||||
record = await session.scalar(
|
||||
select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(remote_user.uuid))
|
||||
)
|
||||
|
||||
if record is None:
|
||||
record = RemnawaveUser(rw_uuid=str(remote_user.uuid))
|
||||
session.add(record)
|
||||
|
||||
owner_id = owner.id if owner is not None else await self._find_owner_id(session, remote_user.telegram_id)
|
||||
|
||||
record.owner_telegram_user_id = owner_id
|
||||
record.rw_id = remote_user.id
|
||||
record.short_uuid = remote_user.short_uuid
|
||||
record.username = remote_user.username
|
||||
record.status = remote_user.status
|
||||
record.traffic_limit_bytes = remote_user.traffic_limit_bytes
|
||||
record.used_traffic_bytes = remote_user.user_traffic.used_traffic_bytes
|
||||
record.lifetime_used_traffic_bytes = remote_user.user_traffic.lifetime_used_traffic_bytes
|
||||
record.expire_at = self._normalize_datetime(remote_user.expire_at)
|
||||
record.telegram_id = remote_user.telegram_id
|
||||
record.email = remote_user.email
|
||||
record.description = remote_user.description
|
||||
record.tag = remote_user.tag
|
||||
record.hwid_device_limit = remote_user.hwid_device_limit
|
||||
record.external_squad_uuid = self._maybe_str(remote_user.external_squad_uuid)
|
||||
record.trojan_password = remote_user.trojan_password
|
||||
record.vless_uuid = str(remote_user.vless_uuid)
|
||||
record.ss_password = remote_user.ss_password
|
||||
record.last_triggered_threshold = remote_user.last_triggered_threshold
|
||||
record.sub_revoked_at = self._normalize_datetime(remote_user.sub_revoked_at)
|
||||
record.sub_last_user_agent = remote_user.sub_last_user_agent
|
||||
record.sub_last_opened_at = self._normalize_datetime(remote_user.sub_last_opened_at)
|
||||
record.last_traffic_reset_at = self._normalize_datetime(remote_user.last_traffic_reset_at)
|
||||
record.subscription_url = remote_user.subscription_url
|
||||
record.online_at = self._normalize_datetime(remote_user.user_traffic.online_at)
|
||||
record.first_connected_at = self._normalize_datetime(remote_user.user_traffic.first_connected_at)
|
||||
record.last_connected_node_uuid = self._maybe_str(remote_user.user_traffic.last_connected_node_uuid)
|
||||
record.remote_created_at = self._normalize_datetime(remote_user.created_at)
|
||||
record.remote_updated_at = self._normalize_datetime(remote_user.updated_at)
|
||||
record.synced_at = utcnow()
|
||||
|
||||
await session.flush()
|
||||
await session.execute(
|
||||
delete(RemnawaveUserInternalSquad).where(
|
||||
RemnawaveUserInternalSquad.user_id == record.id
|
||||
)
|
||||
)
|
||||
|
||||
for squad in remote_user.active_internal_squads:
|
||||
squad_uuid = str(squad.uuid)
|
||||
squad_record = await session.get(InternalSquad, squad_uuid)
|
||||
if squad_record is None:
|
||||
squad_record = InternalSquad(uuid=squad_uuid, name=squad.name, synced_at=utcnow())
|
||||
session.add(squad_record)
|
||||
else:
|
||||
squad_record.name = squad.name
|
||||
squad_record.synced_at = utcnow()
|
||||
|
||||
session.add(
|
||||
RemnawaveUserInternalSquad(
|
||||
user_id=record.id,
|
||||
squad_uuid=squad_uuid,
|
||||
)
|
||||
)
|
||||
|
||||
await session.flush()
|
||||
return record
|
||||
|
||||
async def _upsert_history(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_uuid: str,
|
||||
history: SubscriptionRequestHistory,
|
||||
) -> None:
|
||||
for item in history.records:
|
||||
existing = await session.scalar(
|
||||
select(SubscriptionRequestLog).where(
|
||||
SubscriptionRequestLog.remote_id == item.id
|
||||
)
|
||||
)
|
||||
if existing is None:
|
||||
existing = SubscriptionRequestLog(remote_id=item.id, user_uuid=user_uuid)
|
||||
session.add(existing)
|
||||
|
||||
existing.user_uuid = user_uuid
|
||||
existing.request_at = self._normalize_datetime(item.request_at)
|
||||
existing.request_ip = item.request_ip
|
||||
existing.user_agent = item.user_agent
|
||||
existing.synced_at = utcnow()
|
||||
|
||||
async def _upsert_referral_invite(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
invited: TelegramUser,
|
||||
referred_by_telegram_id: int | None,
|
||||
) -> bool:
|
||||
if referred_by_telegram_id is None or referred_by_telegram_id == invited.telegram_id:
|
||||
return False
|
||||
|
||||
existing = await session.scalar(
|
||||
select(ReferralInvite).where(
|
||||
ReferralInvite.invited_telegram_id == invited.telegram_id
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
return False
|
||||
|
||||
display_name = " ".join(
|
||||
part for part in [invited.first_name, invited.last_name] if part
|
||||
).strip() or invited.username or str(invited.telegram_id)
|
||||
|
||||
session.add(
|
||||
ReferralInvite(
|
||||
inviter_telegram_id=referred_by_telegram_id,
|
||||
invited_telegram_id=invited.telegram_id,
|
||||
invited_username=invited.username,
|
||||
invited_display_name=display_name,
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
async def _get_or_create_referral_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
telegram_id: int,
|
||||
) -> str:
|
||||
existing = await session.scalar(
|
||||
select(ReferralCode).where(ReferralCode.telegram_id == telegram_id)
|
||||
)
|
||||
if existing is not None:
|
||||
return existing.code
|
||||
|
||||
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
while True:
|
||||
code = "".join(secrets.choice(alphabet) for _ in range(8))
|
||||
duplicate = await session.scalar(
|
||||
select(ReferralCode.id).where(ReferralCode.code == code)
|
||||
)
|
||||
if duplicate is None:
|
||||
session.add(ReferralCode(telegram_id=telegram_id, code=code))
|
||||
await session.flush()
|
||||
return code
|
||||
|
||||
async def _find_inviter_by_referral_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
referred_by_referral_code: str | None,
|
||||
) -> int | None:
|
||||
raw_code = (referred_by_referral_code or "").strip().upper()
|
||||
if not raw_code:
|
||||
return None
|
||||
|
||||
return await session.scalar(
|
||||
select(ReferralCode.telegram_id).where(ReferralCode.code == raw_code)
|
||||
)
|
||||
|
||||
async def _find_owner_id(self, session: AsyncSession, telegram_id: int | None) -> int | None:
|
||||
if telegram_id is None:
|
||||
return None
|
||||
|
||||
return await session.scalar(
|
||||
select(TelegramUser.id).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_datetime(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value
|
||||
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
@staticmethod
|
||||
def _maybe_str(value: object | None) -> str | None:
|
||||
return str(value) if value is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _build_views(rows: list[tuple[RemnawaveUser, str | None]]) -> list[CachedUserView]:
|
||||
view_map: dict[int, CachedUserView] = {}
|
||||
for user, squad_name in rows:
|
||||
view = view_map.setdefault(user.id, CachedUserView(record=user))
|
||||
if squad_name and squad_name not in view.internal_squads:
|
||||
view.internal_squads.append(squad_name)
|
||||
return list(view_map.values())
|
||||
1
app/utils/__init__.py
Normal file
1
app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Utility helpers."""
|
||||
182
app/utils/formatters.py
Normal file
182
app/utils/formatters.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
def format_bytes(value: int | float | None) -> str:
|
||||
if value is None or value <= 0:
|
||||
return "0 B"
|
||||
|
||||
units = ["B", "KB", "MB", "GB", "TB", "PB"]
|
||||
size = float(value)
|
||||
unit_index = 0
|
||||
|
||||
while size >= 1024 and unit_index < len(units) - 1:
|
||||
size /= 1024
|
||||
unit_index += 1
|
||||
|
||||
if unit_index == 0:
|
||||
return f"{int(size)} {units[unit_index]}"
|
||||
return f"{size:.2f} {units[unit_index]}"
|
||||
|
||||
|
||||
def format_datetime(value: datetime | None) -> str:
|
||||
if value is None:
|
||||
return "не указано"
|
||||
return value.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
|
||||
def format_traffic_limit(limit_bytes: int | None) -> str:
|
||||
if limit_bytes is None or limit_bytes <= 0:
|
||||
return "без лимита"
|
||||
return format_bytes(limit_bytes)
|
||||
|
||||
|
||||
def _format_days_word(value: int) -> str:
|
||||
remainder_100 = value % 100
|
||||
remainder_10 = value % 10
|
||||
if 11 <= remainder_100 <= 14:
|
||||
return "дней"
|
||||
if remainder_10 == 1:
|
||||
return "день"
|
||||
if 2 <= remainder_10 <= 4:
|
||||
return "дня"
|
||||
return "дней"
|
||||
|
||||
|
||||
def format_datetime_with_days_left(value: datetime | None) -> str:
|
||||
if value is None:
|
||||
return "не указано"
|
||||
|
||||
now = datetime.now(value.tzinfo) if value.tzinfo is not None else datetime.now()
|
||||
seconds_left = (value - now).total_seconds()
|
||||
if seconds_left <= 0:
|
||||
return f"{format_datetime(value)} (истёк)"
|
||||
|
||||
days_left = max(1, math.ceil(seconds_left / 86400))
|
||||
return f"{format_datetime(value)} ({days_left} {_format_days_word(days_left)})"
|
||||
|
||||
|
||||
def build_help_text(*, is_admin: bool) -> str:
|
||||
lines = [
|
||||
"<b>Команды</b>",
|
||||
"/start - открыть главное меню",
|
||||
"",
|
||||
"Дальше используйте кнопки внутри интерфейса бота.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_payment_plan(
|
||||
*,
|
||||
title: str,
|
||||
description: str,
|
||||
price_stars: int,
|
||||
duration_days: int,
|
||||
traffic_limit_bytes: int,
|
||||
) -> str:
|
||||
lines = [
|
||||
"<b>Тариф</b>",
|
||||
"",
|
||||
f"<b>{html.escape(title)}</b>",
|
||||
html.escape(description),
|
||||
"",
|
||||
f"Срок: <b>{duration_days} дн.</b>",
|
||||
f"Трафик: <b>{format_traffic_limit(traffic_limit_bytes)}</b>",
|
||||
f"Цена: <b>{price_stars} ₽</b>",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_issued_access(
|
||||
*,
|
||||
subscription_url: str,
|
||||
expire_at: datetime,
|
||||
traffic_limit_bytes: int,
|
||||
brand_name: str = "",
|
||||
is_renewal: bool = False,
|
||||
) -> str:
|
||||
normalized_brand_name = html.escape(brand_name.strip()) if brand_name.strip() else "VPN"
|
||||
title = (
|
||||
f"<b>🔥 {normalized_brand_name} — подписка продлена</b>"
|
||||
if is_renewal
|
||||
else f"<b>🔥 {normalized_brand_name} — доступ готов</b>"
|
||||
)
|
||||
status_text = (
|
||||
"Подписка продлена. Откройте её кнопкой ниже "
|
||||
"или импортируйте ссылку вручную в ваш VPN-клиент."
|
||||
if is_renewal
|
||||
else "Подписка активирована. Откройте её кнопкой ниже "
|
||||
"или импортируйте ссылку вручную в ваш VPN-клиент."
|
||||
)
|
||||
lines = [
|
||||
title,
|
||||
"",
|
||||
f"<blockquote>{status_text}</blockquote>",
|
||||
"",
|
||||
"<b>🔗 Ключ / подписка</b>",
|
||||
html.escape(subscription_url),
|
||||
"",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"⏳ Действует до: <b>{format_datetime_with_days_left(expire_at)}</b>\n"
|
||||
f"📶 Трафик: <b>{format_traffic_limit(traffic_limit_bytes)}</b>"
|
||||
"</blockquote>"
|
||||
),
|
||||
"",
|
||||
"<b>📲 Что делать дальше</b>",
|
||||
"1. Нажмите кнопку «Открыть подписку» ниже или откройте ссылку выше.",
|
||||
"2. Импортируйте подписку или ключ в приложение VPN-клиента.",
|
||||
"3. Управлять доступом и продлевать его можно в панели бота.",
|
||||
"",
|
||||
"<i>Если ссылка не открывается автоматически, скопируйте её в клиент вручную.</i>",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_user_card(
|
||||
*,
|
||||
user: Any,
|
||||
squad_names: list[str],
|
||||
recent_requests: list[Any] | None = None,
|
||||
) -> str:
|
||||
lines = [
|
||||
f"<b>{html.escape(user.username)}</b> <code>{html.escape(user.status)}</code>",
|
||||
f"UUID: <code>{html.escape(user.rw_uuid)}</code>",
|
||||
f"Short UUID: <code>{html.escape(user.short_uuid)}</code>",
|
||||
f"Telegram ID: <code>{user.telegram_id if user.telegram_id is not None else '-'}</code>",
|
||||
f"Истекает: <b>{format_datetime(user.expire_at)}</b>",
|
||||
(
|
||||
"Трафик: "
|
||||
f"<b>{format_bytes(user.used_traffic_bytes)}</b> / "
|
||||
f"<b>{format_traffic_limit(user.traffic_limit_bytes)}</b>"
|
||||
),
|
||||
f"За все время: <b>{format_bytes(user.lifetime_used_traffic_bytes)}</b>",
|
||||
f"Последний UA: {html.escape(user.sub_last_user_agent or 'неизвестно')}",
|
||||
f"Последнее открытие подписки: {format_datetime(user.sub_last_opened_at)}",
|
||||
f"Подписка: {html.escape(user.subscription_url)}",
|
||||
]
|
||||
|
||||
if user.email:
|
||||
lines.append(f"Email: {html.escape(user.email)}")
|
||||
if user.tag:
|
||||
lines.append(f"Tag: <code>{html.escape(user.tag)}</code>")
|
||||
if user.description:
|
||||
lines.append(f"Описание: {html.escape(user.description)}")
|
||||
if squad_names:
|
||||
lines.append(f"Squads: {html.escape(', '.join(sorted(squad_names)))}")
|
||||
|
||||
if recent_requests:
|
||||
lines.append("")
|
||||
lines.append("<b>Последние запросы подписки</b>")
|
||||
for request in recent_requests:
|
||||
lines.append(
|
||||
f"- {format_datetime(request.request_at)} | "
|
||||
f"IP: {html.escape(request.request_ip or '-')} | "
|
||||
f"UA: {html.escape((request.user_agent or '-')[:80])}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
BIN
assets/Thumbs.db
Normal file
BIN
assets/Thumbs.db
Normal file
Binary file not shown.
BIN
assets/main-white-backup.png
Normal file
BIN
assets/main-white-backup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
assets/main.png
Normal file
BIN
assets/main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
@@ -1,59 +0,0 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Загрузка переменных окружения
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class Config:
|
||||
"""Класс для управления конфигурацией"""
|
||||
|
||||
def __init__(self):
|
||||
# Telegram
|
||||
self.bot_token = os.getenv("BOT_TOKEN", "")
|
||||
|
||||
# Database
|
||||
self.db_host = os.getenv("DB_HOST", "localhost")
|
||||
self.db_user = os.getenv("DB_USER", "root")
|
||||
self.db_password = os.getenv("DB_PASSWORD", "")
|
||||
self.db_name = os.getenv("DB_NAME", "botyobshik")
|
||||
|
||||
# Support
|
||||
self.support_username = os.getenv("SUPPORT_USERNAME", "@support_username")
|
||||
|
||||
# Payment
|
||||
self.payment_channel_id = os.getenv("PAYMENT_CHANNEL_ID", "")
|
||||
self.payment_thread_id = os.getenv("PAYMENT_THREAD_ID", "")
|
||||
self.payment_ticket_channel_id = os.getenv("PAYMENT_TICKET_CHANNEL_ID", "")
|
||||
self.payment_ticket_thread_id = os.getenv("PAYMENT_TICKET_THREAD_ID", "")
|
||||
self.payment_phone = os.getenv("PAYMENT_PHONE", "+7 904 788 77 35")
|
||||
|
||||
# Admin
|
||||
admin_ids_str = os.getenv("ADMIN_IDS", "")
|
||||
self.admin_ids = [int(x.strip()) for x in admin_ids_str.split(",") if x.strip().isdigit()]
|
||||
|
||||
# Remnawave API
|
||||
self.remwave_api_url = os.getenv("REMWAVE_API_URL", "")
|
||||
self.remwave_api_key = os.getenv("REMWAVE_API_KEY", "")
|
||||
|
||||
@property
|
||||
def db_config(self) -> dict:
|
||||
"""Конфигурация подключения к БД"""
|
||||
return {
|
||||
"host": self.db_host,
|
||||
"user": self.db_user,
|
||||
"password": self.db_password,
|
||||
}
|
||||
|
||||
@property
|
||||
def db_config_with_name(self) -> dict:
|
||||
"""Конфигурация подключения к БД с именем базы"""
|
||||
return {
|
||||
**self.db_config,
|
||||
"database": self.db_name
|
||||
}
|
||||
|
||||
|
||||
# Глобальный экземпляр конфигурации
|
||||
config = Config()
|
||||
518
core/__init__.py
518
core/__init__.py
@@ -1,518 +0,0 @@
|
||||
import mysql.connector
|
||||
from mysql.connector import Error
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
from functools import wraps
|
||||
|
||||
from config import config
|
||||
|
||||
|
||||
def run_sync(func):
|
||||
"""Декоратор для запуска синхронных функций в async"""
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, func, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
class Database:
|
||||
"""Класс для работы с базой данных"""
|
||||
|
||||
def __init__(self):
|
||||
self.db_config = config.db_config
|
||||
self.db_config_with_name = config.db_config_with_name
|
||||
|
||||
def get_connection(self, with_db: bool = True):
|
||||
"""Подключение к MySQL"""
|
||||
db_config = self.db_config_with_name if with_db else self.db_config
|
||||
try:
|
||||
connection = mysql.connector.connect(**db_config)
|
||||
return connection
|
||||
except Error as e:
|
||||
print(f"Ошибка подключения к БД: {e}")
|
||||
return None
|
||||
|
||||
async def create_database_if_not_exists(self) -> bool:
|
||||
"""Создание базы данных если она не существует"""
|
||||
connection = self.get_connection(with_db=False)
|
||||
if not connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(
|
||||
f"CREATE DATABASE IF NOT EXISTS `{config.db_name}` "
|
||||
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
|
||||
)
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
print(f"База данных '{config.db_name}' проверена/создана")
|
||||
return True
|
||||
except Error as e:
|
||||
print(f"Ошибка создания БД: {e}")
|
||||
return False
|
||||
|
||||
async def create_tables(self) -> bool:
|
||||
"""Создание таблиц в базе данных"""
|
||||
connection = self.get_connection(with_db=True)
|
||||
if not connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
|
||||
# Таблица подписок
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNIQUE NOT NULL,
|
||||
username VARCHAR(255),
|
||||
is_active BOOLEAN DEFAULT FALSE,
|
||||
subscription_start DATETIME,
|
||||
subscription_end DATETIME,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Таблица языков пользователей
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_languages (
|
||||
user_id BIGINT PRIMARY KEY,
|
||||
language_code VARCHAR(10) DEFAULT 'ru',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Таблица тикетов
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
username VARCHAR(255),
|
||||
message TEXT NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'open',
|
||||
admin_response TEXT,
|
||||
closed_by VARCHAR(255),
|
||||
closed_at DATETIME,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status)
|
||||
)
|
||||
""")
|
||||
|
||||
# Индексы
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_id ON subscriptions(user_id)")
|
||||
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
print("Таблицы созданы/проверены")
|
||||
return True
|
||||
except Error as e:
|
||||
print(f"Ошибка создания таблиц: {e}")
|
||||
return False
|
||||
|
||||
async def initialize(self):
|
||||
"""Инициализация БД: создание БД и таблиц"""
|
||||
await self.create_database_if_not_exists()
|
||||
await self.create_tables()
|
||||
|
||||
@run_sync
|
||||
def get_user_subscription(self, user_id: int) -> bool:
|
||||
"""Проверка статуса подписки пользователя"""
|
||||
connection = self.get_connection()
|
||||
if not connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"SELECT is_active FROM subscriptions WHERE user_id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
return result['is_active'] if result else False
|
||||
except Error as e:
|
||||
print(f"Ошибка БД: {e}")
|
||||
return False
|
||||
|
||||
@run_sync
|
||||
def create_user(self, user_id: int, username: str) -> bool:
|
||||
"""Создание нового пользователя в базе"""
|
||||
connection = self.get_connection()
|
||||
if not connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(
|
||||
"""INSERT INTO subscriptions (user_id, username, is_active)
|
||||
VALUES (%s, %s, FALSE)
|
||||
ON DUPLICATE KEY UPDATE username = %s""",
|
||||
(user_id, username, username)
|
||||
)
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
return True
|
||||
except Error as e:
|
||||
print(f"Ошибка БД: {e}")
|
||||
return False
|
||||
|
||||
@run_sync
|
||||
def activate_subscription(self, user_id: int, days: int = 30) -> bool:
|
||||
"""Активация подписки на указанное количество дней"""
|
||||
connection = self.get_connection()
|
||||
if not connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
now = datetime.now()
|
||||
end_date = now + timedelta(days=days)
|
||||
|
||||
cursor.execute(
|
||||
"""INSERT INTO subscriptions (user_id, is_active, subscription_start, subscription_end)
|
||||
VALUES (%s, TRUE, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
is_active = TRUE,
|
||||
subscription_start = %s,
|
||||
subscription_end = %s""",
|
||||
(user_id, now, end_date, now, end_date)
|
||||
)
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
return True
|
||||
except Error as e:
|
||||
print(f"Ошибка БД: {e}")
|
||||
return False
|
||||
|
||||
@run_sync
|
||||
def deactivate_subscription(self, user_id: int) -> bool:
|
||||
"""Деактивация подписки"""
|
||||
connection = self.get_connection()
|
||||
if not connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE subscriptions SET is_active = FALSE WHERE user_id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
return True
|
||||
except Error as e:
|
||||
print(f"Ошибка БД: {e}")
|
||||
return False
|
||||
|
||||
@run_sync
|
||||
def get_subscription_end_date(self, user_id: int) -> Optional[datetime]:
|
||||
"""Получение даты окончания подписки"""
|
||||
connection = self.get_connection()
|
||||
if not connection:
|
||||
return None
|
||||
|
||||
try:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"SELECT subscription_end FROM subscriptions WHERE user_id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
return result['subscription_end'] if result and result['subscription_end'] else None
|
||||
except Error as e:
|
||||
print(f"Ошибка БД: {e}")
|
||||
return None
|
||||
|
||||
@run_sync
|
||||
def get_user_language(self, user_id: int) -> str:
|
||||
"""Получение языка пользователя"""
|
||||
connection = self.get_connection()
|
||||
if not connection:
|
||||
return "ru"
|
||||
|
||||
try:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"SELECT language_code FROM user_languages WHERE user_id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
return result['language_code'] if result else "ru"
|
||||
except Error as e:
|
||||
print(f"Ошибка БД: {e}")
|
||||
return "ru"
|
||||
|
||||
@run_sync
|
||||
def set_user_language(self, user_id: int, language: str) -> bool:
|
||||
"""Установка языка пользователя"""
|
||||
connection = self.get_connection()
|
||||
if not connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(
|
||||
"""INSERT INTO user_languages (user_id, language_code)
|
||||
VALUES (%s, %s)
|
||||
ON DUPLICATE KEY UPDATE language_code = %s""",
|
||||
(user_id, language, language)
|
||||
)
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
return True
|
||||
except Error as e:
|
||||
print(f"Ошибка БД: {e}")
|
||||
return False
|
||||
|
||||
@run_sync
|
||||
def create_ticket(self, user_id: int, username: str, message: str) -> Optional[int]:
|
||||
"""Создание тикета"""
|
||||
connection = self.get_connection()
|
||||
if not connection:
|
||||
return None
|
||||
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(
|
||||
"""INSERT INTO tickets (user_id, username, message, status)
|
||||
VALUES (%s, %s, %s, 'open')""",
|
||||
(user_id, username, message)
|
||||
)
|
||||
connection.commit()
|
||||
ticket_id = cursor.lastrowid
|
||||
cursor.close()
|
||||
connection.close()
|
||||
return ticket_id
|
||||
except Error as e:
|
||||
print(f"Ошибка БД: {e}")
|
||||
return None
|
||||
|
||||
@run_sync
|
||||
def get_user_tickets(self, user_id: int) -> list:
|
||||
"""Получение всех тикетов пользователя"""
|
||||
connection = self.get_connection()
|
||||
if not connection:
|
||||
return []
|
||||
|
||||
try:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"""SELECT id, message, status, admin_response, created_at
|
||||
FROM tickets
|
||||
WHERE user_id = %s
|
||||
ORDER BY created_at DESC""",
|
||||
(user_id,)
|
||||
)
|
||||
tickets = cursor.fetchall()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
return tickets
|
||||
except Error as e:
|
||||
print(f"Ошибка БД: {e}")
|
||||
return []
|
||||
|
||||
@run_sync
|
||||
def get_ticket(self, ticket_id: int) -> Optional[dict]:
|
||||
"""Получение информации о тикете"""
|
||||
connection = self.get_connection()
|
||||
if not connection:
|
||||
return None
|
||||
|
||||
try:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"SELECT * FROM tickets WHERE id = %s",
|
||||
(ticket_id,)
|
||||
)
|
||||
ticket = cursor.fetchone()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
return ticket
|
||||
except Error as e:
|
||||
print(f"Ошибка БД: {e}")
|
||||
return None
|
||||
|
||||
@run_sync
|
||||
def update_ticket_response(self, ticket_id: int, admin_response: str, status: str = 'answered') -> bool:
|
||||
"""Обновление ответа администратора в тикете"""
|
||||
connection = self.get_connection()
|
||||
if not connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(
|
||||
"""UPDATE tickets
|
||||
SET admin_response = %s, status = %s
|
||||
WHERE id = %s""",
|
||||
(admin_response, status, ticket_id)
|
||||
)
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
return True
|
||||
except Error as e:
|
||||
print(f"Ошибка БД: {e}")
|
||||
return False
|
||||
|
||||
@run_sync
|
||||
def close_ticket(self, ticket_id, closed_by):
|
||||
"""Закрытие тикета"""
|
||||
connection = self.get_connection()
|
||||
if not connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(
|
||||
"""UPDATE tickets
|
||||
SET status = 'closed', closed_by = %s, closed_at = %s
|
||||
WHERE id = %s""",
|
||||
(closed_by, datetime.now(), ticket_id)
|
||||
)
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
return True
|
||||
except Error as e:
|
||||
print(f"Ошибка БД: {e}")
|
||||
return False
|
||||
|
||||
@run_sync
|
||||
def append_ticket_message(self, ticket_id: int, user_message: str) -> bool:
|
||||
"""Добавление сообщения к тикету"""
|
||||
connection = self.get_connection()
|
||||
if not connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
# Получаем текущее сообщение
|
||||
cursor.execute("SELECT message FROM tickets WHERE id = %s", (ticket_id,))
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
current_message = result[0]
|
||||
new_message = f"{current_message}\n\n➕ Дополнение:\n{user_message}"
|
||||
cursor.execute(
|
||||
"UPDATE tickets SET message = %s WHERE id = %s",
|
||||
(new_message, ticket_id)
|
||||
)
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
return True
|
||||
return False
|
||||
except Error as e:
|
||||
print(f"Ошибка БД: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@run_sync
|
||||
def get_all_users_paginated(self, page, per_page=10):
|
||||
"""
|
||||
Получение всех пользователей с пагинацией
|
||||
|
||||
Args:
|
||||
page: Номер страницы (начиная с 1)
|
||||
per_page: Количество пользователей на странице
|
||||
|
||||
Returns:
|
||||
dict: {'users': [...], 'total': int, 'total_pages': int, 'current_page': int}
|
||||
"""
|
||||
connection = self.get_connection()
|
||||
if not connection:
|
||||
return {'users': [], 'total': 0, 'total_pages': 0, 'current_page': page}
|
||||
|
||||
try:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# Получаем общее количество пользователей
|
||||
cursor.execute("SELECT COUNT(*) as count FROM subscriptions")
|
||||
total = cursor.fetchone()['count']
|
||||
|
||||
# Вычисляем общее количество страниц
|
||||
total_pages = (total + per_page - 1) // per_page if total > 0 else 0
|
||||
|
||||
# Ограничиваем page в допустимых пределах
|
||||
page = max(1, min(page, total_pages if total_pages > 0 else 1))
|
||||
|
||||
# Получаем пользователей с пагинацией
|
||||
offset = (page - 1) * per_page
|
||||
cursor.execute(
|
||||
"""SELECT id, user_id, username, is_active, subscription_start, subscription_end, created_at
|
||||
FROM subscriptions
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s OFFSET %s""",
|
||||
(per_page, offset)
|
||||
)
|
||||
users = cursor.fetchall()
|
||||
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
return {
|
||||
'users': users,
|
||||
'total': total,
|
||||
'total_pages': total_pages,
|
||||
'current_page': page
|
||||
}
|
||||
except Error as e:
|
||||
print(f"Ошибка БД: {e}")
|
||||
return {'users': [], 'total': 0, 'total_pages': 0, 'current_page': page}
|
||||
|
||||
@run_sync
|
||||
def get_users_count(self):
|
||||
"""
|
||||
Получение количества пользователей по статусам
|
||||
|
||||
Returns:
|
||||
dict: {'total': int, 'active': int, 'inactive': int}
|
||||
"""
|
||||
connection = self.get_connection()
|
||||
if not connection:
|
||||
return {'total': 0, 'active': 0, 'inactive': 0}
|
||||
|
||||
try:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("SELECT COUNT(*) as count FROM subscriptions")
|
||||
total = cursor.fetchone()['count']
|
||||
|
||||
cursor.execute("SELECT COUNT(*) as count FROM subscriptions WHERE is_active = TRUE")
|
||||
active = cursor.fetchone()['count']
|
||||
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
return {
|
||||
'total': total,
|
||||
'active': active,
|
||||
'inactive': total - active
|
||||
}
|
||||
except Error as e:
|
||||
print(f"Ошибка БД: {e}")
|
||||
return {'total': 0, 'active': 0, 'inactive': 0}
|
||||
|
||||
|
||||
# Глобальный экземпляр базы данных
|
||||
db = Database()
|
||||
@@ -1,439 +0,0 @@
|
||||
import aiohttp
|
||||
from config import config
|
||||
|
||||
|
||||
class RemnawaveAPI:
|
||||
"""Клиент для работы с Remnawave API"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_url = config.remwave_api_url
|
||||
self.api_key = config.remwave_api_key
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
async def create_subscription(self, user_id: int, days: int, tariff: str) -> dict:
|
||||
"""
|
||||
Создание пользователя (подписки) в Remnawave
|
||||
|
||||
Args:
|
||||
user_id: Telegram user ID
|
||||
days: Количество дней подписки
|
||||
tariff: Название тарифа
|
||||
|
||||
Returns:
|
||||
dict: Результат создания подписки
|
||||
"""
|
||||
# Эндпоинт для создания пользователя
|
||||
url = f"{self.api_url}/api/users"
|
||||
|
||||
# Генерируем случайный username и пароль
|
||||
import random
|
||||
import string
|
||||
random_username = f"subs{user_id}"
|
||||
random_password = ''.join(random.choices(string.ascii_letters + string.digits, k=12))
|
||||
|
||||
# Вычисляем дату истечения
|
||||
from datetime import datetime, timedelta
|
||||
expire_at = (datetime.now() + timedelta(days=days)).isoformat()
|
||||
|
||||
# Данные для создания пользователя (формат Remnawave)
|
||||
data = {
|
||||
"username": random_username,
|
||||
"password": random_password,
|
||||
"telegramId": user_id, # Число, не строка
|
||||
"expireAt": expire_at # ISO формат даты
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
async with session.post(url, json=data, headers=self.headers) as response:
|
||||
result = await response.json()
|
||||
|
||||
if response.status in [200, 201]:
|
||||
print(f"✅ Подписка создана для user {user_id}")
|
||||
print(f"📋 Ответ API: {result}")
|
||||
|
||||
# Получаем ссылку на подключение
|
||||
# API возвращает в формате: {'response': {'subscriptionUrl': '...'}}
|
||||
subscription_url = None
|
||||
if isinstance(result, dict):
|
||||
# Проверяем response.subscriptionUrl (основной вариант)
|
||||
response_data = result.get("response", {})
|
||||
if isinstance(response_data, dict):
|
||||
subscription_url = response_data.get("subscriptionUrl")
|
||||
|
||||
# Если не нашли, проверяем другие варианты
|
||||
if not subscription_url:
|
||||
subscription_url = (
|
||||
result.get("subscriptionUrl") or
|
||||
result.get("subscription") or
|
||||
result.get("link") or
|
||||
result.get("url") or
|
||||
result.get("data", {}).get("subscriptionUrl") or
|
||||
result.get("data", {}).get("subscription") or
|
||||
result.get("data", {}).get("link")
|
||||
)
|
||||
|
||||
print(f"🔗 Subscription URL: {subscription_url}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result,
|
||||
"subscription_url": subscription_url,
|
||||
"username": random_username
|
||||
}
|
||||
else:
|
||||
print(f"❌ Ошибка создания подписки: {response.status} - {result}")
|
||||
return {"success": False, "error": result, "status": response.status}
|
||||
except aiohttp.ClientError as e:
|
||||
print(f"❌ Ошибка подключения к Remnawave API: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def get_user_subscription(self, user_id: int) -> dict:
|
||||
"""
|
||||
Получение информации о подписке пользователя
|
||||
|
||||
Args:
|
||||
user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
dict: Информация о подписке
|
||||
"""
|
||||
url = f"{self.api_url}/api/users/telegram/{user_id}"
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
async with session.get(url, headers=self.headers) as response:
|
||||
result = await response.json()
|
||||
|
||||
if response.status == 200:
|
||||
return {"success": True, "data": result}
|
||||
else:
|
||||
return {"success": False, "error": result, "status": response.status}
|
||||
except aiohttp.ClientError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def extend_subscription(self, user_id: int, days: int) -> dict:
|
||||
"""
|
||||
Продление подписки
|
||||
|
||||
Args:
|
||||
user_id: Telegram user ID
|
||||
days: Количество дней для продления
|
||||
|
||||
Returns:
|
||||
dict: Результат продления
|
||||
"""
|
||||
url = f"{self.api_url}/api/users/telegram/{user_id}/extend"
|
||||
|
||||
data = {
|
||||
"durationDays": days
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
async with session.post(url, json=data, headers=self.headers) as response:
|
||||
result = await response.json()
|
||||
|
||||
if response.status == 200:
|
||||
print(f"✅ Подписка продлена для user {user_id} на {days} дней")
|
||||
return {"success": True, "data": result}
|
||||
else:
|
||||
print(f"❌ Ошибка продления подписки: {response.status} - {result}")
|
||||
return {"success": False, "error": result, "status": response.status}
|
||||
except aiohttp.ClientError as e:
|
||||
print(f"❌ Ошибка подключения к Remnawave API: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def get_all_users(self, page: int = 0, size: int = 500) -> dict:
|
||||
"""
|
||||
Получение всех пользователей из Remnawave с пагинацией
|
||||
|
||||
Args:
|
||||
page: Номер страницы (начиная с 0)
|
||||
size: Размер страницы (макс 500)
|
||||
|
||||
Returns:
|
||||
dict: Список пользователей
|
||||
"""
|
||||
url = f"{self.api_url}/api/users"
|
||||
params = {"start": page, "size": min(size, 500)}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
async with session.get(url, headers=self.headers, params=params) as response:
|
||||
result = await response.json()
|
||||
print(f"📋 Remnawave API ответ: status={response.status}, keys={list(result.keys()) if isinstance(result, dict) else type(result)}")
|
||||
|
||||
if response.status == 200:
|
||||
# Remnawave возвращает {'response': {'content': [...], 'total': N}}
|
||||
users_list = []
|
||||
|
||||
if isinstance(result, dict):
|
||||
# Пробуем разные варианты извлечения списка
|
||||
if 'response' in result and isinstance(result['response'], dict):
|
||||
response_data = result['response']
|
||||
if 'content' in response_data and isinstance(response_data['content'], list):
|
||||
users_list = response_data['content']
|
||||
print(f"✅ Получено {len(users_list)} пользователей (response.content)")
|
||||
elif 'users' in response_data and isinstance(response_data['users'], list):
|
||||
users_list = response_data['users']
|
||||
print(f"✅ Получено {len(users_list)} пользователей (response.users)")
|
||||
elif 'content' in result and isinstance(result['content'], list):
|
||||
users_list = result['content']
|
||||
print(f"✅ Получено {len(users_list)} пользователей (content)")
|
||||
elif 'data' in result and isinstance(result['data'], list):
|
||||
users_list = result['data']
|
||||
print(f"✅ Получено {len(users_list)} пользователей (data)")
|
||||
elif 'users' in result and isinstance(result['users'], list):
|
||||
users_list = result['users']
|
||||
print(f"✅ Получено {len(users_list)} пользователей (users)")
|
||||
|
||||
if users_list:
|
||||
return {"success": True, "data": {"content": users_list, "total": len(users_list)}}
|
||||
|
||||
print(f"⚠️ Не найдено пользователей в ответе")
|
||||
return {"success": True, "data": {"content": [], "total": 0}}
|
||||
else:
|
||||
print(f"❌ Ошибка получения пользователей: {response.status} - {result}")
|
||||
return {"success": False, "error": result, "status": response.status}
|
||||
except aiohttp.ClientError as e:
|
||||
print(f"❌ Ошибка подключения к Remnawave API: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def sync_user(self, user_id: int, username: str, days: int = 30) -> dict:
|
||||
"""
|
||||
Синхронизация пользователя (создание если нет в Remnawave)
|
||||
|
||||
Args:
|
||||
user_id: Telegram user ID
|
||||
username: Имя пользователя
|
||||
days: Количество дней
|
||||
|
||||
Returns:
|
||||
dict: Результат синхронизации
|
||||
"""
|
||||
# Сначала пробуем найти пользователя по Telegram ID через API
|
||||
print(f"🔍 Поиск пользователя по Telegram ID: {user_id}")
|
||||
check_result = await self.get_user_subscription(user_id)
|
||||
|
||||
if check_result.get("success") and check_result.get("data"):
|
||||
# Пользователь уже есть в Remnawave с этим telegramId
|
||||
print(f"✅ Пользователь {user_id} уже найден в Remnawave по Telegram ID")
|
||||
return {"success": True, "exists": True, "data": check_result.get("data")}
|
||||
|
||||
# Если не нашли по telegramId, пробуем найти по username (subs{user_id})
|
||||
remnawave_username = f"subs{user_id}"
|
||||
print(f"🔍 Не найдено по Telegram ID, поиск по username: {remnawave_username}")
|
||||
|
||||
# Получаем всех пользователей и ищем по username
|
||||
all_users_result = await self.get_all_users(page=0, size=500)
|
||||
|
||||
if all_users_result.get("success"):
|
||||
users_data = all_users_result.get("data", {})
|
||||
users_list = users_data.get("content", [])
|
||||
|
||||
print(f"📋 Получено {len(users_list)} пользователей из Remnawave")
|
||||
|
||||
# Логируем первые несколько для отладки
|
||||
if users_list and len(users_list) > 0:
|
||||
first_users = [(u.get("username"), u.get("telegramId")) for u in users_list[:5] if isinstance(u, dict)]
|
||||
print(f"📋 Первые пользователи: {first_users}")
|
||||
|
||||
# Ищем по username
|
||||
for user in users_list:
|
||||
if isinstance(user, dict) and user.get("username") == remnawave_username:
|
||||
user_uuid = user.get("uuid")
|
||||
print(f"✅ Найден пользователь {remnawave_username} с UUID {user_uuid}")
|
||||
if user_uuid:
|
||||
update_result = await self._update_user_telegram_id(user_uuid, user_id)
|
||||
if update_result.get("success"):
|
||||
print(f"✅ Обновлён telegramId для user {user_id}")
|
||||
return {"success": True, "exists": True, "updated": True, "data": update_result.get("data")}
|
||||
else:
|
||||
return update_result
|
||||
|
||||
# Создаём нового пользователя
|
||||
print(f"➕ Создание нового пользователя {remnawave_username}")
|
||||
create_result = await self.create_subscription(user_id, days, remnawave_username)
|
||||
|
||||
# Если ошибка "username already exists" — пользователь уже есть, это нормально
|
||||
if not create_result.get("success"):
|
||||
error_data = create_result.get("error", {})
|
||||
if isinstance(error_data, dict) and error_data.get("message") == "User username already exists":
|
||||
print(f"ℹ️ Пользователь {remnawave_username} уже существует в Remnawave (не удалось обновить telegramId)")
|
||||
# Возвращаем успех с флагом exists
|
||||
return {"success": True, "exists": True, "updated": False, "data": None}
|
||||
else:
|
||||
print(f"❌ Ошибка создания: {error_data}")
|
||||
|
||||
return create_result
|
||||
|
||||
async def _find_and_update_by_username(self, username: str, telegram_id: int) -> dict:
|
||||
"""
|
||||
Поиск пользователя по username и обновление telegramId
|
||||
|
||||
Args:
|
||||
username: Имя пользователя в Remnawave
|
||||
telegram_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
dict: Результат
|
||||
"""
|
||||
all_users_result = await self.get_all_users(page=0, size=500)
|
||||
|
||||
if not all_users_result.get("success"):
|
||||
return all_users_result
|
||||
|
||||
users_data = all_users_result.get("data", {})
|
||||
users_list = users_data.get("content", []) if isinstance(users_data, dict) else (users_data if isinstance(users_data, list) else [])
|
||||
|
||||
for user in users_list:
|
||||
if user.get("username") == username:
|
||||
user_uuid = user.get("uuid")
|
||||
if user_uuid:
|
||||
return await self._update_user_telegram_id(user_uuid, telegram_id)
|
||||
|
||||
return {"success": False, "error": "User not found"}
|
||||
|
||||
async def _update_user_telegram_id(self, user_uuid: str, telegram_id: int) -> dict:
|
||||
"""
|
||||
Обновление telegramId у существующего пользователя
|
||||
|
||||
Args:
|
||||
user_uuid: UUID пользователя в Remnawave
|
||||
telegram_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
dict: Результат обновления
|
||||
"""
|
||||
# Пробуем несколько вариантов обновления
|
||||
urls_to_try = [
|
||||
(f"{self.api_url}/api/users/{user_uuid}/telegram", "PATCH"),
|
||||
(f"{self.api_url}/api/users/{user_uuid}", "PUT"),
|
||||
(f"{self.api_url}/api/users/{user_uuid}/telegram-id", "POST"),
|
||||
]
|
||||
|
||||
for url, method in urls_to_try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
data = {"telegramId": telegram_id}
|
||||
|
||||
if method == "PATCH":
|
||||
async with session.patch(url, json=data, headers=self.headers) as response:
|
||||
result = await response.json()
|
||||
if response.status in [200, 204]:
|
||||
print(f"✅ Обновлён telegramId для user {telegram_id} (PATCH)")
|
||||
return {"success": True, "data": result}
|
||||
elif method == "PUT":
|
||||
async with session.put(url, json=data, headers=self.headers) as response:
|
||||
result = await response.json()
|
||||
if response.status in [200, 204]:
|
||||
print(f"✅ Обновлён telegramId для user {telegram_id} (PUT)")
|
||||
return {"success": True, "data": result}
|
||||
elif method == "POST":
|
||||
async with session.post(url, json=data, headers=self.headers) as response:
|
||||
result = await response.json()
|
||||
if response.status in [200, 201]:
|
||||
print(f"✅ Обновлён telegramId для user {telegram_id} (POST)")
|
||||
return {"success": True, "data": result}
|
||||
except aiohttp.ClientError as e:
|
||||
continue
|
||||
|
||||
print(f"❌ Не удалось обновить telegramId - API не поддерживает обновление")
|
||||
return {"success": False, "error": "API does not support user update"}
|
||||
|
||||
async def sync_all_users(self) -> dict:
|
||||
"""
|
||||
Синхронизация всех пользователей: Remnawave → Локальная БД
|
||||
|
||||
Returns:
|
||||
dict: Результаты синхронизации
|
||||
"""
|
||||
from core import db as local_db
|
||||
|
||||
# Получаем всех пользователей из Remnawave
|
||||
print("🔄 Получение пользователей из Remnawave...")
|
||||
remnawave_result = await self.get_all_users(page=0, size=500)
|
||||
|
||||
if not remnawave_result.get("success"):
|
||||
return {"success": False, "error": "Не удалось получить пользователей из Remnawave"}
|
||||
|
||||
users_data = remnawave_result.get("data", {})
|
||||
users_list = users_data.get("content", [])
|
||||
|
||||
print(f"📋 Получено {len(users_list)} пользователей из Remnawave")
|
||||
|
||||
total = len(users_list)
|
||||
imported = 0
|
||||
updated = 0
|
||||
errors = 0
|
||||
error_details = []
|
||||
|
||||
connection = local_db.get_connection()
|
||||
cursor = connection.cursor()
|
||||
|
||||
for user in users_list:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
|
||||
user_id = user.get("telegramId")
|
||||
username = user.get("username", "")
|
||||
is_active = user.get("status") == "ACTIVE"
|
||||
|
||||
if not user_id:
|
||||
# Нет Telegram ID - пропускаем
|
||||
continue
|
||||
|
||||
try:
|
||||
# Проверяем, есть ли пользователь в БД
|
||||
cursor.execute(
|
||||
"SELECT id FROM subscriptions WHERE user_id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
existing = cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
# Обновляем существующего
|
||||
cursor.execute(
|
||||
"""UPDATE subscriptions
|
||||
SET username = %s, is_active = %s
|
||||
WHERE user_id = %s""",
|
||||
(username, is_active, user_id)
|
||||
)
|
||||
updated += 1
|
||||
print(f"🔄 Обновлён пользователь {user_id} ({username})")
|
||||
else:
|
||||
# Создаём нового
|
||||
cursor.execute(
|
||||
"""INSERT INTO subscriptions (user_id, username, is_active)
|
||||
VALUES (%s, %s, %s)""",
|
||||
(user_id, username, is_active)
|
||||
)
|
||||
imported += 1
|
||||
print(f"➕ Импортирован пользователь {user_id} ({username})")
|
||||
|
||||
connection.commit()
|
||||
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
error_details.append(f"User {user_id}: {str(e)}")
|
||||
print(f"❌ Ошибка импорта пользователя {user_id}: {e}")
|
||||
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total": total,
|
||||
"imported": imported,
|
||||
"updated": updated,
|
||||
"errors": errors,
|
||||
"error_details": error_details[:10]
|
||||
}
|
||||
|
||||
|
||||
# Глобальный экземпляр API
|
||||
remnawave = RemnawaveAPI()
|
||||
@@ -1,38 +0,0 @@
|
||||
-- Обновление таблицы tickets для добавления полей закрытия
|
||||
USE botyobshik;
|
||||
|
||||
-- Проверяем и добавляем колонку closed_by
|
||||
SET @dbname = DATABASE();
|
||||
SET @tablename = 'tickets';
|
||||
SET @columnname = 'closed_by';
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
(table_name = @tablename)
|
||||
AND (table_schema = @dbname)
|
||||
AND (column_name = @columnname)
|
||||
) > 0,
|
||||
'SELECT 1',
|
||||
CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(255) AFTER admin_response')
|
||||
));
|
||||
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||
EXECUTE alterIfNotExists;
|
||||
DEALLOCATE PREPARE alterIfNotExists;
|
||||
|
||||
-- Проверяем и добавляем колонку closed_at
|
||||
SET @columnname = 'closed_at';
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
(table_name = @tablename)
|
||||
AND (table_schema = @dbname)
|
||||
AND (column_name = @columnname)
|
||||
) > 0,
|
||||
'SELECT 1',
|
||||
CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' DATETIME AFTER closed_by')
|
||||
));
|
||||
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||
EXECUTE alterIfNotExists;
|
||||
DEALLOCATE PREPARE alterIfNotExists;
|
||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
services:
|
||||
bot:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_PORT: 3306
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: mariadb:11.7
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_DATABASE: ${DB_NAME:-telegabot}
|
||||
MARIADB_USER: ${DB_USER:-telegabot}
|
||||
MARIADB_PASSWORD: ${DB_PASSWORD:-telegabot_password}
|
||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root_password}
|
||||
volumes:
|
||||
- mariadb_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mariadb-admin ping -h localhost -p$$MARIADB_ROOT_PASSWORD --silent"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
mariadb_data:
|
||||
1793
handlers/__init__.py
1793
handlers/__init__.py
File diff suppressed because it is too large
Load Diff
@@ -1,150 +0,0 @@
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from locales import localization
|
||||
|
||||
|
||||
class Keyboards:
|
||||
"""Класс для создания клавиатур"""
|
||||
|
||||
@staticmethod
|
||||
def get_main_keyboard(locale: str = "ru") -> InlineKeyboardMarkup:
|
||||
"""Основная клавиатура бота"""
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=localization.get("buttons.buy_subscription", locale),
|
||||
callback_data="buy_subscription"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=localization.get("buttons.support", locale),
|
||||
callback_data="support"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=localization.get("buttons.rules", locale),
|
||||
callback_data="rules"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=localization.get("buttons.language", locale),
|
||||
callback_data="language"
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
return keyboard
|
||||
|
||||
@staticmethod
|
||||
def get_buy_subscription_keyboard(locale: str = "ru") -> InlineKeyboardMarkup:
|
||||
"""Клавиатура для покупки подписки"""
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=localization.get("tariffs.14_days", locale),
|
||||
callback_data="buy_14_days"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=localization.get("tariffs.30_days", locale),
|
||||
callback_data="buy_30_days"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=localization.get("tariffs.60_days", locale),
|
||||
callback_data="buy_60_days"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=localization.get("tariffs.90_days", locale),
|
||||
callback_data="buy_90_days"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=localization.get("tariffs.180_days", locale),
|
||||
callback_data="buy_180_days"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=localization.get("tariffs.360_days", locale),
|
||||
callback_data="buy_360_days"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=localization.get("buttons.back", locale),
|
||||
callback_data="back_to_main"
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
return keyboard
|
||||
|
||||
@staticmethod
|
||||
def get_language_keyboard(locale: str = "ru") -> InlineKeyboardMarkup:
|
||||
"""Клавиатура выбора языка"""
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=localization.get("languages.ru", locale),
|
||||
callback_data="lang_ru"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=localization.get("languages.en", locale),
|
||||
callback_data="lang_en"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=localization.get("languages.kz", locale),
|
||||
callback_data="lang_kz"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=localization.get("buttons.back", locale),
|
||||
callback_data="back_to_main"
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
return keyboard
|
||||
|
||||
@staticmethod
|
||||
def get_support_keyboard(locale: str = "ru") -> InlineKeyboardMarkup:
|
||||
"""Клавиатура техподдержки"""
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📝 Создать тикет",
|
||||
callback_data="support_create_ticket"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📋 Мои тикеты",
|
||||
callback_data="support_my_tickets"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=localization.get("buttons.back", locale),
|
||||
callback_data="back_to_main"
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
return keyboard
|
||||
@@ -1,24 +0,0 @@
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
|
||||
class PaymentKeyboards:
|
||||
"""Клавиатуры для оплаты"""
|
||||
|
||||
@staticmethod
|
||||
def get_payment_approval_keyboard(user_id: int, tariff: str, days: int) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура для подтверждения оплаты (в канал)"""
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✅ Аппрув",
|
||||
callback_data=f"payment_approve_{user_id}_{tariff}_{days}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="❌ Отклонить",
|
||||
callback_data=f"payment_decline_{user_id}_{tariff}"
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
return keyboard
|
||||
@@ -1,91 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Localization:
|
||||
"""Класс для управления локализацией"""
|
||||
|
||||
_instance = None
|
||||
_locales = {}
|
||||
_default_locale = "ru"
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not self._locales:
|
||||
self.load_locales()
|
||||
|
||||
def load_locales(self):
|
||||
"""Загрузка всех файлов локализации"""
|
||||
locales_dir = Path(__file__).parent
|
||||
for locale_file in locales_dir.glob("*.json"):
|
||||
locale_code = locale_file.stem
|
||||
with open(locale_file, "r", encoding="utf-8") as f:
|
||||
self._locales[locale_code] = json.load(f)
|
||||
|
||||
def get(self, key: str, locale: str = None, **kwargs) -> str:
|
||||
"""
|
||||
Получение локализованной строки
|
||||
|
||||
Args:
|
||||
key: Ключ строки (например, "welcome", "buttons.buy_subscription")
|
||||
locale: Код языка (ru, en, kz)
|
||||
**kwargs: Параметры для форматирования строки
|
||||
"""
|
||||
if locale is None:
|
||||
locale = self._default_locale
|
||||
|
||||
if locale not in self._locales:
|
||||
locale = self._default_locale
|
||||
|
||||
keys = key.split(".")
|
||||
value = self._locales.get(locale, {})
|
||||
|
||||
for k in keys:
|
||||
if isinstance(value, dict):
|
||||
value = value.get(k)
|
||||
else:
|
||||
value = None
|
||||
break
|
||||
|
||||
if value is None:
|
||||
# Пытаемся найти в default locale
|
||||
value = self._locales.get(self._default_locale, {})
|
||||
for k in keys:
|
||||
if isinstance(value, dict):
|
||||
value = value.get(k)
|
||||
else:
|
||||
value = None
|
||||
break
|
||||
|
||||
if value is None:
|
||||
return key
|
||||
|
||||
if kwargs:
|
||||
try:
|
||||
value = value.format(**kwargs)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return value
|
||||
|
||||
def get_locale_name(self, locale: str) -> str:
|
||||
"""Получение названия языка на родном языке"""
|
||||
return self.get(f"languages.{locale}", locale)
|
||||
|
||||
def set_default_locale(self, locale: str):
|
||||
"""Установка языка по умолчанию"""
|
||||
if locale in self._locales:
|
||||
self._default_locale = locale
|
||||
|
||||
def get_available_locales(self) -> list:
|
||||
"""Получение списка доступных языков"""
|
||||
return list(self._locales.keys())
|
||||
|
||||
|
||||
# Глобальный экземпляр локализации
|
||||
localization = Localization()
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"welcome": "Hello, {username}!",
|
||||
"subscription_status": "Your subscription: {status}",
|
||||
"select_action": "Select an action:",
|
||||
"subscription_active": "Active",
|
||||
"subscription_inactive": "Inactive",
|
||||
"buttons": {
|
||||
"buy_subscription": "💳 Buy Subscription",
|
||||
"support": "📞 Support",
|
||||
"rules": "📜 Service Rules",
|
||||
"language": "🌐 Language",
|
||||
"back": "🔙 Back"
|
||||
},
|
||||
"messages": {
|
||||
"support": "Support: {username}",
|
||||
"support_title": "📞 Support",
|
||||
"support_description": "This is the ticket center: create requests, view responses and history.\n\n📝 Create ticket - describe your problem or question.\n📋 My tickets - status and correspondence\n\nTry to use tickets - this way we can help faster and nothing gets lost.",
|
||||
"rules_title": "📜 VPN Rules",
|
||||
"rules_text": "1. By using the bot, you agree to the rules and use the service lawfully.\n2. VPN is designed to protect data and secure internet access.\n3. Prohibited: hacking, spam, fraud, viruses and illegal content 🚫\n4. User is solely responsible for their actions.\n5. Technical data may be collected (not shared with third parties, except by law).\n6. Access may be restricted without refund for violations.\n\n⚠️ VPN does not guarantee complete anonymity.",
|
||||
"select_tariff": "Select tariff:",
|
||||
"tariff_selected": "You selected: {tariff}",
|
||||
"select_language": "Выберите язык / Select language:",
|
||||
"language_changed": "Language changed to {language}",
|
||||
"payment_instruction_title": "Hello! 👋",
|
||||
"payment_instruction_text": "To purchase a subscription, follow these steps:\n\n1️⃣ Transfer via SBP to: {phone}\n2️⃣ After payment, send the receipt or screenshot to this chat\n\nAfter payment confirmation, we will activate your subscription.\n\nThank you! 😊",
|
||||
"payment_receipt_sent": "✅ Receipt received! Wait for payment confirmation."
|
||||
},
|
||||
"subscription": {
|
||||
"standard_title": "Standard Subscription",
|
||||
"standard_description": "Basic tariff plan"
|
||||
},
|
||||
"tariffs": {
|
||||
"14_days": "14 days - 150₽",
|
||||
"30_days": "30 days - 300₽",
|
||||
"60_days": "60 days - 600₽",
|
||||
"90_days": "90 days - 900₽",
|
||||
"180_days": "180 days - 1800₽",
|
||||
"360_days": "360 days - 3600₽"
|
||||
},
|
||||
"languages": {
|
||||
"ru": "🇷🇺 Русский",
|
||||
"en": "🇬🇧 English",
|
||||
"kz": "🇰🇿 Қазақша"
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"welcome": "Сәлем, {username}!",
|
||||
"subscription_status": "Сіздің жазылымыңыз: {status}",
|
||||
"select_action": "Әрекетті таңдаңыз:",
|
||||
"subscription_active": "Белсенді",
|
||||
"subscription_inactive": "Белсенді емес",
|
||||
"buttons": {
|
||||
"buy_subscription": "💳 Жазылуды сатып алу",
|
||||
"support": "📞 Қолдау қызметі",
|
||||
"rules": "📜 Қызмет ережелері",
|
||||
"language": "🌐 Тіл",
|
||||
"back": "🔙 Артқа"
|
||||
},
|
||||
"messages": {
|
||||
"support": "Қолдау қызметі: {username}",
|
||||
"support_title": "📞 Қолдау",
|
||||
"support_description": "Бұл тикет орталығы: өтініштер жасау, жауаптарды және тарихты қарау.\n\n📝 Тикет жасау - мәселеңізді немесе сұрағыңызды сипаттаңыз.\n📋 Менің тикеттерім - мәртебе және хат алмасу\n\nТикеттерді пайдаланыңыз - біз жылдамырақ көмектесеміз және ештеңе жоғалмайды.",
|
||||
"rules_title": "📜 VPN ережелері",
|
||||
"rules_text": "1. Ботты пайдалана отырып, сіз ережелермен келісесіз және сервисді заңды түрде пайдаланасыз.\n2. VPN деректерді қорғау және қауіпсіз интернетке қол жеткізу үшін арналған.\n3. Тыйым салынады: бұзу, спам, алаяқтық, вирустар және заңсыз контент 🚫\n4. Пайдаланушы өз әрекеттері үшін дербес жауап береді.\n5. Техникалық деректер жиналуы мүмкін (заңды жағдайларды қоспағанда, үшінші тұлғаларға берілмейді).\n6. Ережелерді бұзған жағдайда қол жеткізу қайтарусыз шектелуі мүмкін.\n\n⚠️ VPN толық анонимділікті кепілдік бермейді.",
|
||||
"select_tariff": "Тарифті таңдаңыз:",
|
||||
"tariff_selected": "Сіз таңдадыңыз: {tariff}",
|
||||
"select_language": "Выберите язык / Select language:",
|
||||
"language_changed": "Тіл өзгертілді: {language}",
|
||||
"payment_instruction_title": "Сәлем! 👋",
|
||||
"payment_instruction_text": "Жазылымды сатып алу үшін мына әрекеттерді орындаңыз:\n\n1️⃣ СБП арқылы {phone} нөміріне аударыңыз\n2️⃣ Төлемнен кейін чекті немесе скриншотты осы чатқа жіберіңіз\n\nТөлем расталғаннан кейін жазылымыңызды белсендіреміз.\n\nРақмет! 😊",
|
||||
"payment_receipt_sent": "✅ Чек қабылданды! Төлемнің расталуын күтіңіз."
|
||||
},
|
||||
"subscription": {
|
||||
"standard_title": "Стандартты жазылым",
|
||||
"standard_description": "Негізгі тарифтік жоспар"
|
||||
},
|
||||
"tariffs": {
|
||||
"14_days": "14 күн - 150₽",
|
||||
"30_days": "30 күн - 300₽",
|
||||
"60_days": "60 күн - 600₽",
|
||||
"90_days": "90 күн - 900₽",
|
||||
"180_days": "180 күн - 1800₽",
|
||||
"360_days": "360 күн - 3600₽"
|
||||
},
|
||||
"languages": {
|
||||
"ru": "🇷🇺 Русский",
|
||||
"en": "🇬🇧 English",
|
||||
"kz": "🇰🇿 Қазақша"
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"welcome": "Привет, {username}!",
|
||||
"subscription_status": "Твоя подписка: {status}",
|
||||
"select_action": "Выберите действие:",
|
||||
"subscription_active": "Активна",
|
||||
"subscription_inactive": "Не активна",
|
||||
"buttons": {
|
||||
"buy_subscription": "💳 Купить подписку",
|
||||
"support": "📞 Техподдержка",
|
||||
"rules": "📜 Правила сервиса",
|
||||
"language": "🌐 Язык",
|
||||
"back": "🔙 Назад"
|
||||
},
|
||||
"messages": {
|
||||
"support": "Техподдержка: {username}",
|
||||
"support_title": "📞 Поддержка",
|
||||
"support_description": "Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n📝 Создать тикет - опишите проблему или вопрос.\n📋 Мои тикеты - статус и переписка\n\nСтарайтесь использовать тикеты - так мы быстрее поможем и ничего не потеряется.",
|
||||
"rules_title": "📜 Правила VPN",
|
||||
"rules_text": "1. Используя бота, вы соглашаетесь с правилами и используете сервис законно.\n2. VPN предназначен для защиты данных и безопасного доступа к интернету.\n3. Запрещены: взломы, спам, мошенничество, вирусы и незаконный контент 🚫\n4. Пользователь сам несёт ответственность за свои действия.\n5. Возможен сбор технических данных (без передачи третьим лицам, кроме закона).\n6. За нарушения доступ может быть ограничен без возврата средств.\n\n⚠️ VPN не гарантирует полной анонимности.",
|
||||
"select_tariff": "Выберите тариф:",
|
||||
"tariff_selected": "Вы выбрали тариф: {tariff}",
|
||||
"select_language": "Выберите язык / Select language:",
|
||||
"language_changed": "Язык изменён на {language}",
|
||||
"payment_instruction_title": "Здравствуйте! 👋",
|
||||
"payment_instruction_text": "Чтобы приобрести подписку, выполните следующие шаги:\n\n1️⃣ Переведите сумму через СБП по номеру: {phone}\n2️⃣ После оплаты отправьте чек или скриншот перевода в этот чат\n\nПосле подтверждения оплаты мы активируем вашу подписку.\n\nСпасибо! 😊",
|
||||
"payment_receipt_sent": "✅ Чек получен! Ожидайте подтверждения оплаты."
|
||||
},
|
||||
"subscription": {
|
||||
"standard_title": "Стандартная подписка",
|
||||
"standard_description": "Базовый тарифный план"
|
||||
},
|
||||
"tariffs": {
|
||||
"14_days": "14 дней - 150₽",
|
||||
"30_days": "30 дней - 300₽",
|
||||
"60_days": "60 дней - 600₽",
|
||||
"90_days": "90 дней - 900₽",
|
||||
"180_days": "180 дней - 1800₽",
|
||||
"360_days": "360 дней - 3600₽"
|
||||
},
|
||||
"languages": {
|
||||
"ru": "🇷🇺 Русский",
|
||||
"en": "🇬🇧 English",
|
||||
"kz": "🇰🇿 Қазақша"
|
||||
}
|
||||
}
|
||||
36
main.py
36
main.py
@@ -1,40 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
|
||||
from config import config
|
||||
from core import db
|
||||
from handlers import main_router, admin_router
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
# Инициализация бота и диспетчера
|
||||
bot = Bot(token=config.bot_token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
dp = Dispatcher()
|
||||
|
||||
# Регистрация роутеров
|
||||
dp.include_router(main_router)
|
||||
dp.include_router(admin_router)
|
||||
|
||||
|
||||
async def on_startup():
|
||||
"""Инициализация при запуске"""
|
||||
# Создаём БД и таблицы если нет
|
||||
await db.initialize()
|
||||
print("Бот запущен...")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Запуск бота"""
|
||||
await on_startup()
|
||||
await dp.start_polling(bot)
|
||||
from app.main import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
33
pyproject.toml
Normal file
33
pyproject.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.27.0"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "telegabot"
|
||||
version = "0.1.0"
|
||||
description = "Telegram bot on aiogram with MariaDB and Remnawave API integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"aiogram>=3.20.0,<4.0.0",
|
||||
"aiohttp-socks>=0.10.1,<0.11.0",
|
||||
"aiomysql>=0.2.0,<0.3.0",
|
||||
"httpx>=0.28.0,<0.29.0",
|
||||
"pydantic-settings>=2.7.0,<3.0.0",
|
||||
"python-dotenv>=1.0.1,<2.0.0",
|
||||
"SQLAlchemy>=2.0.36,<3.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"aiosqlite>=0.21.0,<1.0.0",
|
||||
"pytest>=8.3.4,<9.0.0",
|
||||
"pytest-asyncio>=0.25.3,<1.0.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
@@ -1,4 +0,0 @@
|
||||
aiogram==3.3.0
|
||||
mysql-connector-python==8.3.0
|
||||
python-dotenv==1.0.1
|
||||
aiohttp==3.9.3
|
||||
126
run.bat
Normal file
126
run.bat
Normal file
@@ -0,0 +1,126 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
pushd "%SCRIPT_DIR%" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Failed to switch to project directory:
|
||||
echo %SCRIPT_DIR%
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist ".env" (
|
||||
echo [ERROR] .env file was not found.
|
||||
echo Copy .env.example to .env and fill in BOT_TOKEN, REMNAWAVE_* and DB_* values.
|
||||
popd
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
call :find_python
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Python was not found in PATH.
|
||||
echo Install Python 3.12+ and make sure ^`py^` or ^`python^` is available.
|
||||
popd
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
call :ensure_venv
|
||||
if errorlevel 1 (
|
||||
popd
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [INFO] Installing dependencies...
|
||||
".venv\Scripts\python.exe" -m pip install -e .
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Failed to install dependencies
|
||||
popd
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [INFO] Starting bot...
|
||||
".venv\Scripts\python.exe" main.py
|
||||
set "EXIT_CODE=%ERRORLEVEL%"
|
||||
|
||||
if not "%EXIT_CODE%"=="0" (
|
||||
echo [ERROR] Bot stopped with code %EXIT_CODE%
|
||||
popd
|
||||
pause
|
||||
exit /b %EXIT_CODE%
|
||||
)
|
||||
|
||||
popd
|
||||
exit /b 0
|
||||
|
||||
:find_python
|
||||
set "BOOTSTRAP_PY="
|
||||
|
||||
py -3 --version >nul 2>&1
|
||||
if not errorlevel 1 (
|
||||
set "BOOTSTRAP_PY=py -3"
|
||||
echo [INFO] Using bootstrap interpreter: %BOOTSTRAP_PY%
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
py --version >nul 2>&1
|
||||
if not errorlevel 1 (
|
||||
set "BOOTSTRAP_PY=py"
|
||||
echo [INFO] Using bootstrap interpreter: %BOOTSTRAP_PY%
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
python --version >nul 2>&1
|
||||
if not errorlevel 1 (
|
||||
set "BOOTSTRAP_PY=python"
|
||||
echo [INFO] Using bootstrap interpreter: %BOOTSTRAP_PY%
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
exit /b 1
|
||||
|
||||
:ensure_venv
|
||||
set "RECREATE_VENV="
|
||||
|
||||
if not exist ".venv\Scripts\python.exe" (
|
||||
set "RECREATE_VENV=1"
|
||||
)
|
||||
|
||||
if not defined RECREATE_VENV (
|
||||
".venv\Scripts\python.exe" -c "import sys; print(sys.executable)" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
set "RECREATE_VENV=1"
|
||||
)
|
||||
)
|
||||
|
||||
if defined RECREATE_VENV (
|
||||
if exist ".venv" (
|
||||
echo [WARN] Existing .venv is invalid or was created on another machine.
|
||||
echo [INFO] Recreating virtual environment...
|
||||
rmdir /s /q ".venv"
|
||||
if exist ".venv" (
|
||||
echo [ERROR] Failed to remove broken .venv
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
echo [INFO] Creating virtual environment...
|
||||
)
|
||||
|
||||
%BOOTSTRAP_PY% -m venv .venv
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Failed to create .venv
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
".venv\Scripts\python.exe" -m pip --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Virtual environment exists, but pip is unavailable.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
exit /b 0
|
||||
173
sql/schema.sql
Normal file
173
sql/schema.sql
Normal file
@@ -0,0 +1,173 @@
|
||||
CREATE TABLE IF NOT EXISTS telegram_users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
telegram_id BIGINT NOT NULL UNIQUE,
|
||||
username VARCHAR(64) NULL,
|
||||
first_name VARCHAR(255) NULL,
|
||||
last_name VARCHAR(255) NULL,
|
||||
language_code VARCHAR(16) NULL,
|
||||
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_blocked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_seen_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
INDEX ix_telegram_users_telegram_id (telegram_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS remnawave_users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
owner_telegram_user_id INT NULL,
|
||||
rw_uuid CHAR(36) NOT NULL UNIQUE,
|
||||
rw_id BIGINT NOT NULL UNIQUE,
|
||||
short_uuid VARCHAR(48) NOT NULL UNIQUE,
|
||||
username VARCHAR(36) NOT NULL UNIQUE,
|
||||
status VARCHAR(16) NOT NULL,
|
||||
traffic_limit_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
used_traffic_bytes DOUBLE NOT NULL DEFAULT 0,
|
||||
lifetime_used_traffic_bytes DOUBLE NOT NULL DEFAULT 0,
|
||||
expire_at DATETIME NOT NULL,
|
||||
telegram_id BIGINT NULL,
|
||||
email VARCHAR(320) NULL,
|
||||
description TEXT NULL,
|
||||
tag VARCHAR(16) NULL,
|
||||
hwid_device_limit INT NULL,
|
||||
external_squad_uuid CHAR(36) NULL,
|
||||
trojan_password VARCHAR(64) NOT NULL,
|
||||
vless_uuid CHAR(36) NOT NULL,
|
||||
ss_password VARCHAR(64) NOT NULL,
|
||||
last_triggered_threshold INT NOT NULL DEFAULT 0,
|
||||
sub_revoked_at DATETIME NULL,
|
||||
sub_last_user_agent VARCHAR(512) NULL,
|
||||
sub_last_opened_at DATETIME NULL,
|
||||
last_traffic_reset_at DATETIME NULL,
|
||||
subscription_url TEXT NOT NULL,
|
||||
online_at DATETIME NULL,
|
||||
first_connected_at DATETIME NULL,
|
||||
last_connected_node_uuid CHAR(36) NULL,
|
||||
remote_created_at DATETIME NOT NULL,
|
||||
remote_updated_at DATETIME NOT NULL,
|
||||
synced_at DATETIME NOT NULL,
|
||||
CONSTRAINT fk_remnawave_users_owner
|
||||
FOREIGN KEY (owner_telegram_user_id)
|
||||
REFERENCES telegram_users (id)
|
||||
ON DELETE SET NULL,
|
||||
INDEX ix_remnawave_users_rw_uuid (rw_uuid),
|
||||
INDEX ix_remnawave_users_short_uuid (short_uuid),
|
||||
INDEX ix_remnawave_users_username (username),
|
||||
INDEX ix_remnawave_users_telegram_id (telegram_id),
|
||||
INDEX ix_remnawave_users_email (email),
|
||||
INDEX ix_remnawave_users_tag (tag)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internal_squads (
|
||||
uuid CHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
synced_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS remnawave_user_internal_squads (
|
||||
user_id INT NOT NULL,
|
||||
squad_uuid CHAR(36) NOT NULL,
|
||||
PRIMARY KEY (user_id, squad_uuid),
|
||||
CONSTRAINT fk_rw_user_squad_user
|
||||
FOREIGN KEY (user_id)
|
||||
REFERENCES remnawave_users (id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_rw_user_squad_squad
|
||||
FOREIGN KEY (squad_uuid)
|
||||
REFERENCES internal_squads (uuid)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subscription_request_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
remote_id BIGINT NOT NULL UNIQUE,
|
||||
user_uuid CHAR(36) NOT NULL,
|
||||
request_at DATETIME NOT NULL,
|
||||
request_ip VARCHAR(45) NULL,
|
||||
user_agent TEXT NULL,
|
||||
synced_at DATETIME NOT NULL,
|
||||
CONSTRAINT fk_subscription_request_logs_user
|
||||
FOREIGN KEY (user_uuid)
|
||||
REFERENCES remnawave_users (rw_uuid)
|
||||
ON DELETE CASCADE,
|
||||
INDEX ix_subscription_request_logs_user_uuid (user_uuid),
|
||||
INDEX ix_subscription_request_logs_request_at (request_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS support_tickets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
public_id VARCHAR(32) NOT NULL UNIQUE,
|
||||
telegram_id BIGINT NOT NULL,
|
||||
username VARCHAR(64) NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
user_message TEXT NOT NULL,
|
||||
support_chat_id BIGINT NOT NULL,
|
||||
support_thread_id INT NULL,
|
||||
support_message_id BIGINT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE KEY ix_support_tickets_chat_message (support_chat_id, support_message_id),
|
||||
INDEX ix_support_tickets_public_id (public_id),
|
||||
INDEX ix_support_tickets_telegram_id (telegram_id),
|
||||
INDEX ix_support_tickets_support_chat_id (support_chat_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS referral_invites (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
inviter_telegram_id BIGINT NOT NULL,
|
||||
invited_telegram_id BIGINT NOT NULL UNIQUE,
|
||||
invited_username VARCHAR(64) NULL,
|
||||
invited_display_name VARCHAR(255) NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
INDEX ix_referral_invites_inviter_telegram_id (inviter_telegram_id),
|
||||
INDEX ix_referral_invites_invited_telegram_id (invited_telegram_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS referral_codes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
telegram_id BIGINT NOT NULL UNIQUE,
|
||||
code VARCHAR(32) NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
INDEX ix_referral_codes_telegram_id (telegram_id),
|
||||
INDEX ix_referral_codes_code (code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payment_orders (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
order_uuid CHAR(36) NOT NULL UNIQUE,
|
||||
telegram_user_id INT NOT NULL,
|
||||
telegram_id BIGINT NOT NULL,
|
||||
plan_code VARCHAR(64) NOT NULL,
|
||||
plan_title VARCHAR(255) NOT NULL,
|
||||
plan_duration_days INT NOT NULL,
|
||||
traffic_limit_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
traffic_limit_strategy VARCHAR(32) NOT NULL DEFAULT 'NO_RESET',
|
||||
amount_stars INT NOT NULL,
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'XTR',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
||||
invoice_payload VARCHAR(128) NOT NULL UNIQUE,
|
||||
provision_username VARCHAR(36) NOT NULL,
|
||||
remnawave_user_uuid CHAR(36) NULL,
|
||||
subscription_url TEXT NULL,
|
||||
telegram_payment_charge_id VARCHAR(255) NULL UNIQUE,
|
||||
provider_payment_charge_id VARCHAR(255) NULL,
|
||||
error_message TEXT NULL,
|
||||
paid_at DATETIME NULL,
|
||||
fulfilled_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
CONSTRAINT fk_payment_orders_telegram_user
|
||||
FOREIGN KEY (telegram_user_id)
|
||||
REFERENCES telegram_users (id)
|
||||
ON DELETE CASCADE,
|
||||
INDEX ix_payment_orders_order_uuid (order_uuid),
|
||||
INDEX ix_payment_orders_telegram_user_id (telegram_user_id),
|
||||
INDEX ix_payment_orders_telegram_id (telegram_id),
|
||||
INDEX ix_payment_orders_plan_code (plan_code),
|
||||
INDEX ix_payment_orders_status (status),
|
||||
INDEX ix_payment_orders_invoice_payload (invoice_payload),
|
||||
INDEX ix_payment_orders_provision_username (provision_username),
|
||||
INDEX ix_payment_orders_remnawave_user_uuid (remnawave_user_uuid)
|
||||
);
|
||||
21
tests/conftest.py
Normal file
21
tests/conftest.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session_factory(tmp_path) -> AsyncIterator[async_sessionmaker[AsyncSession]]:
|
||||
database_path = tmp_path / "test.db"
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{database_path}")
|
||||
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
await engine.dispose()
|
||||
61
tests/helpers.py
Normal file
61
tests/helpers.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.schemas.remnawave import RemnawaveUser
|
||||
|
||||
|
||||
def make_remote_user(
|
||||
*,
|
||||
user_uuid: str,
|
||||
user_id: int,
|
||||
short_uuid: str,
|
||||
username: str,
|
||||
telegram_id: int | None,
|
||||
expire_at: datetime | None = None,
|
||||
traffic_limit_bytes: int = 0,
|
||||
) -> RemnawaveUser:
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||
expire = expire_at or (now + timedelta(days=30))
|
||||
|
||||
return RemnawaveUser.model_validate(
|
||||
{
|
||||
"uuid": user_uuid,
|
||||
"id": user_id,
|
||||
"shortUuid": short_uuid,
|
||||
"username": username,
|
||||
"status": "ACTIVE",
|
||||
"trafficLimitBytes": traffic_limit_bytes,
|
||||
"expireAt": expire.isoformat().replace("+00:00", "Z"),
|
||||
"telegramId": telegram_id,
|
||||
"email": None,
|
||||
"description": None,
|
||||
"tag": "BOT",
|
||||
"hwidDeviceLimit": None,
|
||||
"externalSquadUuid": None,
|
||||
"trojanPassword": f"trojan-{user_id}",
|
||||
"vlessUuid": f"00000000-0000-0000-0000-{user_id:012d}",
|
||||
"ssPassword": f"ss-{user_id}",
|
||||
"lastTriggeredThreshold": 0,
|
||||
"subRevokedAt": None,
|
||||
"subLastUserAgent": None,
|
||||
"subLastOpenedAt": None,
|
||||
"lastTrafficResetAt": None,
|
||||
"createdAt": now.isoformat().replace("+00:00", "Z"),
|
||||
"updatedAt": now.isoformat().replace("+00:00", "Z"),
|
||||
"subscriptionUrl": f"https://example.com/sub/{short_uuid}",
|
||||
"activeInternalSquads": [
|
||||
{
|
||||
"uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"name": "VPN",
|
||||
}
|
||||
],
|
||||
"userTraffic": {
|
||||
"usedTrafficBytes": 0,
|
||||
"lifetimeUsedTrafficBytes": 0,
|
||||
"onlineAt": None,
|
||||
"firstConnectedAt": None,
|
||||
"lastConnectedNodeUuid": None,
|
||||
},
|
||||
}
|
||||
)
|
||||
74
tests/test_bot_config_service.py
Normal file
74
tests/test_bot_config_service.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from app.config import Settings
|
||||
from app.services.bot_config_service import BotConfigService
|
||||
|
||||
|
||||
def _make_settings() -> Settings:
|
||||
return Settings.model_construct(
|
||||
bot_brand_name="OREOL VPN",
|
||||
bot_public_username="oreol_vpn_bot",
|
||||
bot_support_url="https://t.me/support_account",
|
||||
bot_terms_url="https://example.com/terms",
|
||||
bot_support_ticket_link="",
|
||||
bot_support_ticket_chat_id_raw="",
|
||||
bot_support_ticket_thread_id=0,
|
||||
bot_start_image_enabled=True,
|
||||
bot_start_image_path="assets/main.png",
|
||||
payment_review_link="",
|
||||
payment_review_chat_id_raw="",
|
||||
payment_review_thread_id=0,
|
||||
referral_discount_percent=5,
|
||||
referral_bonus_days=7,
|
||||
payment_plans_raw="30:250,180:600",
|
||||
payment_transfer_text="Реквизиты",
|
||||
payment_support_text="Напишите в поддержку",
|
||||
payment_plan_traffic_limit_gb=0,
|
||||
payment_plan_traffic_reset_period="NO_RESET",
|
||||
payment_internal_squad_uuids_raw="uuid-1",
|
||||
payment_external_squad_uuid="",
|
||||
payment_username_prefix="Oreol",
|
||||
payment_user_tag="BOT",
|
||||
)
|
||||
|
||||
|
||||
async def test_bot_config_service_updates_runtime_snapshot(session_factory) -> None:
|
||||
service = BotConfigService(
|
||||
settings=_make_settings(),
|
||||
session_factory=session_factory,
|
||||
)
|
||||
|
||||
await service.update_setting(
|
||||
key="bot_brand_name",
|
||||
raw_value="NEW BRAND",
|
||||
updated_by_telegram_id=1,
|
||||
)
|
||||
await service.update_setting(
|
||||
key="payment_plans_raw",
|
||||
raw_value="30:300,90:700",
|
||||
updated_by_telegram_id=1,
|
||||
)
|
||||
await service.update_setting(
|
||||
key="referral_enabled",
|
||||
raw_value="off",
|
||||
updated_by_telegram_id=1,
|
||||
)
|
||||
|
||||
snapshot = await service.get_snapshot()
|
||||
|
||||
assert snapshot.bot_brand_name == "NEW BRAND"
|
||||
assert snapshot.referral_enabled is False
|
||||
assert [(plan.days, plan.amount_rub) for plan in snapshot.payment_plans] == [
|
||||
(30, 300),
|
||||
(90, 700),
|
||||
]
|
||||
|
||||
|
||||
async def test_bot_config_service_formats_values_for_admin_ui(session_factory) -> None:
|
||||
service = BotConfigService(
|
||||
settings=_make_settings(),
|
||||
session_factory=session_factory,
|
||||
)
|
||||
snapshot = await service.get_snapshot()
|
||||
|
||||
assert BotConfigService.format_value(snapshot=snapshot, key="bot_public_username") == "@oreol_vpn_bot"
|
||||
assert BotConfigService.format_value(snapshot=snapshot, key="referral_enabled") == "on"
|
||||
assert BotConfigService.format_value(snapshot=snapshot, key="payment_plan_traffic_limit_gb") == "0 GB (unlimited)"
|
||||
81
tests/test_config.py
Normal file
81
tests/test_config.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def test_database_server_url_excludes_database_name() -> None:
|
||||
settings = Settings.model_construct(
|
||||
db_host="127.0.0.1",
|
||||
db_port=3306,
|
||||
db_name="oreolvpn",
|
||||
db_user="user",
|
||||
db_password="pass",
|
||||
)
|
||||
|
||||
assert settings.database_server_url == "mysql+aiomysql://user:pass@127.0.0.1:3306/?charset=utf8mb4"
|
||||
|
||||
|
||||
def test_support_ticket_link_parses_private_topic_link() -> None:
|
||||
settings = Settings.model_construct(
|
||||
bot_support_ticket_link="https://t.me/c/3646494169/2/3",
|
||||
bot_support_ticket_chat_id_raw="",
|
||||
bot_support_ticket_thread_id=0,
|
||||
)
|
||||
|
||||
assert settings.support_ticket_chat_id == -1003646494169
|
||||
assert settings.support_ticket_message_thread_id == 2
|
||||
|
||||
|
||||
def test_payment_review_link_parses_private_topic_link() -> None:
|
||||
settings = Settings.model_construct(
|
||||
payment_review_link="https://t.me/c/3646494169/56/57",
|
||||
payment_review_chat_id_raw="",
|
||||
payment_review_thread_id=0,
|
||||
)
|
||||
|
||||
assert settings.payment_review_chat_id == -1003646494169
|
||||
assert settings.payment_review_message_thread_id == 56
|
||||
|
||||
|
||||
def test_moderator_ids_are_parsed() -> None:
|
||||
settings = Settings.model_construct(
|
||||
bot_moderator_ids_raw="123, 456 ,789",
|
||||
)
|
||||
|
||||
assert settings.bot_moderator_ids == {123, 456, 789}
|
||||
assert settings.is_moderator(456) is True
|
||||
assert settings.is_moderator(999) is False
|
||||
|
||||
|
||||
def test_public_username_is_normalized() -> None:
|
||||
settings = Settings.model_construct(
|
||||
bot_public_username="@oreol_vpn_bot",
|
||||
)
|
||||
|
||||
assert settings.bot_public_username_normalized == "oreol_vpn_bot"
|
||||
|
||||
|
||||
def test_payment_internal_squad_uuids_are_parsed() -> None:
|
||||
settings = Settings.model_construct(
|
||||
payment_internal_squad_uuids_raw="uuid-1, uuid-2 ,uuid-3",
|
||||
)
|
||||
|
||||
assert settings.payment_internal_squad_uuids == ["uuid-1", "uuid-2", "uuid-3"]
|
||||
|
||||
|
||||
def test_payment_username_prefix_is_normalized() -> None:
|
||||
settings = Settings.model_construct(
|
||||
payment_username_prefix="Oreol!! prefix",
|
||||
)
|
||||
|
||||
assert settings.payment_username_prefix_normalized == "Oreolprefix"
|
||||
|
||||
|
||||
def test_payment_plans_are_parsed() -> None:
|
||||
settings = Settings.model_construct(
|
||||
payment_plans_raw="30:250,180:600,365:1000",
|
||||
)
|
||||
|
||||
plans = settings.payment_plans
|
||||
|
||||
assert [plan.code for plan in plans] == ["30d", "180d", "365d"]
|
||||
assert [plan.days for plan in plans] == [30, 180, 365]
|
||||
assert [plan.amount_rub for plan in plans] == [250, 600, 1000]
|
||||
77
tests/test_db_session.py
Normal file
77
tests/test_db_session.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import pytest
|
||||
|
||||
from app.db.session import _normalize_payment_order_provision_username_index
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._rows)
|
||||
|
||||
|
||||
class _FakeConnection:
|
||||
def __init__(self, *, dialect_name: str, responses: list[list[tuple[str]]]) -> None:
|
||||
self.dialect = type("Dialect", (), {"name": dialect_name})()
|
||||
self._responses = list(responses)
|
||||
self.executed: list[tuple[str, dict[str, object] | None]] = []
|
||||
|
||||
async def execute(self, statement, params=None):
|
||||
self.executed.append((str(statement), params))
|
||||
rows = self._responses.pop(0) if self._responses else []
|
||||
return _FakeResult(rows)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalize_payment_order_index_drops_stale_unique_and_recreates_plain_index() -> None:
|
||||
connection = _FakeConnection(
|
||||
dialect_name="mysql",
|
||||
responses=[
|
||||
[("ix_payment_orders_provision_username",)],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
)
|
||||
|
||||
await _normalize_payment_order_provision_username_index(connection)
|
||||
|
||||
executed_sql = [sql for sql, _ in connection.executed]
|
||||
|
||||
assert any(
|
||||
"DROP INDEX `ix_payment_orders_provision_username` ON `payment_orders`" in sql
|
||||
for sql in executed_sql
|
||||
)
|
||||
assert any(
|
||||
"CREATE INDEX `ix_payment_orders_provision_username` ON `payment_orders` (`provision_username`)"
|
||||
in sql
|
||||
for sql in executed_sql
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalize_payment_order_index_keeps_existing_non_unique_index() -> None:
|
||||
connection = _FakeConnection(
|
||||
dialect_name="mysql",
|
||||
responses=[
|
||||
[],
|
||||
[("custom_payment_orders_provision_username",)],
|
||||
],
|
||||
)
|
||||
|
||||
await _normalize_payment_order_provision_username_index(connection)
|
||||
|
||||
executed_sql = [sql for sql, _ in connection.executed]
|
||||
|
||||
assert not any("DROP INDEX" in sql for sql in executed_sql)
|
||||
assert not any("CREATE INDEX" in sql for sql in executed_sql)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalize_payment_order_index_skips_non_mysql_backends() -> None:
|
||||
connection = _FakeConnection(dialect_name="sqlite", responses=[])
|
||||
|
||||
await _normalize_payment_order_provision_username_index(connection)
|
||||
|
||||
assert connection.executed == []
|
||||
88
tests/test_formatters.py
Normal file
88
tests/test_formatters.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.utils.formatters import (
|
||||
build_help_text,
|
||||
format_bytes,
|
||||
format_datetime,
|
||||
format_datetime_with_days_left,
|
||||
format_issued_access,
|
||||
format_payment_plan,
|
||||
format_traffic_limit,
|
||||
)
|
||||
|
||||
|
||||
def test_format_bytes_for_megabytes() -> None:
|
||||
assert format_bytes(1024 * 1024) == "1.00 MB"
|
||||
|
||||
|
||||
def test_format_traffic_limit_without_limit() -> None:
|
||||
assert format_traffic_limit(0) == "без лимита"
|
||||
|
||||
|
||||
def test_format_datetime_value() -> None:
|
||||
assert format_datetime(datetime(2026, 4, 21, 12, 30)) == "21.04.2026 12:30"
|
||||
|
||||
|
||||
def test_build_help_text_contains_only_start_command() -> None:
|
||||
text = build_help_text(is_admin=False)
|
||||
|
||||
assert "/start - открыть главное меню" in text
|
||||
assert "Дальше используйте кнопки внутри интерфейса бота." in text
|
||||
assert "/menu - " not in text
|
||||
assert "/buy - " not in text
|
||||
assert "/paysupport - " not in text
|
||||
assert "/sync - " not in text
|
||||
assert "/link " not in text
|
||||
assert "/help - " not in text
|
||||
|
||||
|
||||
def test_format_datetime_with_days_left() -> None:
|
||||
value = datetime.now().replace(microsecond=0) + timedelta(days=3, hours=2)
|
||||
|
||||
text = format_datetime_with_days_left(value)
|
||||
|
||||
assert value.strftime("%d.%m.%Y %H:%M") in text
|
||||
assert "(4 " in text
|
||||
|
||||
|
||||
def test_format_payment_plan_uses_rubles() -> None:
|
||||
text = format_payment_plan(
|
||||
title="OREOL VPN на 30 дней",
|
||||
description="Доступ к VPN на 30 дней",
|
||||
price_stars=250,
|
||||
duration_days=30,
|
||||
traffic_limit_bytes=0,
|
||||
)
|
||||
|
||||
assert "OREOL VPN на 30 дней" in text
|
||||
assert "Цена: <b>250 ₽</b>" in text
|
||||
assert "Трафик: <b>без лимита</b>" in text
|
||||
|
||||
|
||||
def test_format_issued_access_contains_subscription_data() -> None:
|
||||
text = format_issued_access(
|
||||
subscription_url="https://example.com/sub",
|
||||
expire_at=datetime(2026, 5, 21, 18, 0),
|
||||
traffic_limit_bytes=0,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "OREOL VPN" in text
|
||||
assert "https://example.com/sub" in text
|
||||
assert "21.05.2026 18:00" in text
|
||||
assert "Подписка активирована" in text
|
||||
assert "Что делать дальше" in text
|
||||
assert "/me</code>" not in text
|
||||
|
||||
|
||||
def test_format_issued_access_for_renewal_mentions_extension() -> None:
|
||||
text = format_issued_access(
|
||||
subscription_url="https://example.com/sub",
|
||||
expire_at=datetime(2026, 5, 21, 18, 0),
|
||||
traffic_limit_bytes=0,
|
||||
brand_name="OREOL VPN",
|
||||
is_renewal=True,
|
||||
)
|
||||
|
||||
assert "подписка продлена" in text.lower()
|
||||
assert "Подписка продлена." in text
|
||||
472
tests/test_panel_ui.py
Normal file
472
tests/test_panel_ui.py
Normal file
@@ -0,0 +1,472 @@
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.bot.ui.panel import (
|
||||
PANEL_SECTION_ADMIN,
|
||||
PANEL_SECTION_HOME,
|
||||
PANEL_SECTION_REFERRAL,
|
||||
PANEL_SECTION_SUBSCRIPTION,
|
||||
PANEL_SECTION_SUPPORT,
|
||||
PANEL_SECTION_TERMS,
|
||||
PanelContext,
|
||||
build_panel_keyboard,
|
||||
render_panel_caption,
|
||||
)
|
||||
from app.services.sync_service import CachedUserView
|
||||
|
||||
|
||||
def _button_texts(markup) -> list[str]:
|
||||
return [button.text for row in markup.inline_keyboard for button in row]
|
||||
|
||||
|
||||
def _cached_user_view(*, telegram_id: int = 123456789) -> CachedUserView:
|
||||
return CachedUserView(
|
||||
record=SimpleNamespace(
|
||||
username="oreol-renew",
|
||||
status="ACTIVE",
|
||||
expire_at=datetime.now() + timedelta(days=10),
|
||||
used_traffic_bytes=0,
|
||||
traffic_limit_bytes=0,
|
||||
short_uuid="renew999",
|
||||
subscription_url="https://example.com/sub/renew999",
|
||||
telegram_id=telegram_id,
|
||||
),
|
||||
internal_squads=["VPN"],
|
||||
)
|
||||
|
||||
|
||||
def test_home_panel_caption_without_subscription() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_HOME,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "Добро пожаловать в <b>OREOL VPN</b>" in caption
|
||||
assert "<b>Профиль</b>" not in caption
|
||||
assert "<b>Подписка</b>" in caption
|
||||
assert "Статус: <b>Не активна</b>" in caption
|
||||
assert "Срок: <b>—</b>" in caption
|
||||
assert "Трафик: <b>—</b>" in caption
|
||||
|
||||
|
||||
def test_home_panel_keyboard_has_expected_buttons() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(section=PANEL_SECTION_HOME, context=context)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert "Профиль" not in button_texts
|
||||
assert "💳 Подписка" in button_texts
|
||||
assert "🆘 Поддержка" in button_texts
|
||||
assert "👥 Реферальная система" in button_texts
|
||||
assert "📜 Условия использования" in button_texts
|
||||
assert len(markup.inline_keyboard[0]) == 1
|
||||
assert markup.inline_keyboard[0][0].text == "💳 Подписка"
|
||||
assert markup.inline_keyboard[0][0].callback_data == "payment:menu"
|
||||
|
||||
|
||||
def test_admin_home_panel_keyboard_has_admin_button() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=True,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(section=PANEL_SECTION_HOME, context=context)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert "🛠 Админ-панель" in button_texts
|
||||
assert len(markup.inline_keyboard[1]) == 1
|
||||
assert markup.inline_keyboard[1][0].text == "🛠 Админ-панель"
|
||||
assert markup.inline_keyboard[1][0].callback_data == "panel:admin"
|
||||
|
||||
|
||||
def test_admin_panel_caption_contains_dashboard_stats() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=True,
|
||||
users=[],
|
||||
admin_total_telegram_users=120,
|
||||
admin_total_cached_users=80,
|
||||
admin_active_cached_users=67,
|
||||
admin_pending_orders=9,
|
||||
admin_review_orders=4,
|
||||
admin_open_tickets=3,
|
||||
admin_pending_referral_bonuses=2,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_ADMIN,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "<b>🛠 Админ-панель OREOL VPN</b>" in caption
|
||||
assert "Пользователей в боте: <b>120</b>" in caption
|
||||
assert "Чеков на проверке: <b>4</b>" in caption
|
||||
assert "Открытых тикетов: <b>3</b>" in caption
|
||||
assert "Бонусов в ожидании: <b>2</b>" in caption
|
||||
|
||||
|
||||
def test_admin_panel_keyboard_has_expected_buttons() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=True,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(
|
||||
section=PANEL_SECTION_ADMIN,
|
||||
context=context,
|
||||
payment_review_url="https://t.me/c/123/56/57",
|
||||
support_ticket_url="https://t.me/c/123/2/3",
|
||||
)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert "🔍 Найти пользователя" in button_texts
|
||||
assert "👤 Пользователи" in button_texts
|
||||
assert "🔄 Полная синхронизация" in button_texts
|
||||
assert "💸 Очередь оплат" in button_texts
|
||||
assert "🎫 Тикеты" in button_texts
|
||||
assert "↻ Обновить" in button_texts
|
||||
assert "🏠 Главное меню" in button_texts
|
||||
|
||||
|
||||
def test_subscription_caption_without_subscription_but_with_payment() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
payment_enabled=True,
|
||||
payment_plan_title="OREOL VPN на 30 дней",
|
||||
payment_plan_description="Доступ к VPN на 30 дней",
|
||||
payment_plan_price_stars=250,
|
||||
payment_plan_duration_days=30,
|
||||
payment_plan_traffic_limit_bytes=0,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_SUBSCRIPTION,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "У вас пока нет активного доступа." in caption
|
||||
assert "OREOL VPN на 30 дней" in caption
|
||||
assert "Цена: <b>250 ₽</b>" in caption
|
||||
assert "После выбора тарифа бот покажет реквизиты" in caption
|
||||
|
||||
|
||||
def test_subscription_caption_without_payment_uses_ui_instructions() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
payment_enabled=False,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_SUBSCRIPTION,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "откройте поддержку" in caption
|
||||
assert "short UUID" in caption
|
||||
assert "/link" not in caption
|
||||
|
||||
|
||||
def test_subscription_keyboard_has_buy_button() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
payment_enabled=True,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(section=PANEL_SECTION_SUBSCRIPTION, context=context)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert "💳 Купить подписку" in button_texts
|
||||
assert "🏠 Главное меню" in button_texts
|
||||
|
||||
|
||||
def test_subscription_keyboard_has_renew_button_when_access_exists() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[_cached_user_view()],
|
||||
payment_enabled=True,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(section=PANEL_SECTION_SUBSCRIPTION, context=context)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert "💳 Продлить подписку" in button_texts
|
||||
assert "💳 Купить подписку" not in button_texts
|
||||
|
||||
|
||||
def test_subscription_caption_with_existing_access_mentions_renewal() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[_cached_user_view()],
|
||||
payment_enabled=True,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_SUBSCRIPTION,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "Продлить подписку" in caption
|
||||
assert "автоматически добавит выбранный срок" in caption
|
||||
|
||||
|
||||
def test_terms_panel_caption() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_TERMS,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "<b>📜 Правила VPN</b>" in caption
|
||||
assert "использовать VPN только в законных целях" in caption
|
||||
assert "не гарантирует полной анонимности" in caption
|
||||
|
||||
|
||||
def test_support_panel_has_create_ticket_button() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(section=PANEL_SECTION_SUPPORT, context=context)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert "🎫 Создать тикет" in button_texts
|
||||
assert "🏠 Главное меню" in button_texts
|
||||
|
||||
|
||||
def test_support_panel_has_support_url_button() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(
|
||||
section=PANEL_SECTION_SUPPORT,
|
||||
context=context,
|
||||
support_url="https://t.me/support_account",
|
||||
)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert "Написать в поддержку" in button_texts
|
||||
|
||||
|
||||
def test_support_panel_caption() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_SUPPORT,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "<b>🆘 Помощь и сопровождение</b>" in caption
|
||||
assert "откройте тикет через кнопку ниже" in caption
|
||||
|
||||
|
||||
def test_referral_panel_caption_with_link() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
referral_code="ABCD1234",
|
||||
referral_count=3,
|
||||
referral_link="https://t.me/oreol_vpn_bot?start=ref_ABCD1234",
|
||||
referral_share_url="https://t.me/share/url?url=test",
|
||||
referral_recent_names=["Alex", "Maria"],
|
||||
referral_discount_percent=12,
|
||||
referral_bonus_days=7,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_REFERRAL,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "<b>👥 Реферальная система</b>" in caption
|
||||
assert "Ваш реферальный код: <code>ABCD1234</code>" in caption
|
||||
assert "Приглашено пользователей: <b>3</b>" in caption
|
||||
assert "+7 дней" in caption
|
||||
assert "Чтобы получить скидку 12%" in caption
|
||||
assert "https://t.me/oreol_vpn_bot?start=ref_ABCD1234" in caption
|
||||
assert "Alex" in caption
|
||||
assert "Maria" in caption
|
||||
|
||||
|
||||
def test_referral_caption_uses_runtime_discount_for_applied_code() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
referral_code="ABCD1234",
|
||||
referral_count=1,
|
||||
applied_referral_code="PROMO777",
|
||||
referral_discount_percent=17,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_REFERRAL,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "Код: <code>PROMO777</code>" in caption
|
||||
assert "Скидка перед оплатой: <b>17%</b>" in caption
|
||||
|
||||
|
||||
def test_admin_panel_keyboard_has_settings_button() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=True,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(section=PANEL_SECTION_ADMIN, context=context)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert any("Настрой" in text for text in button_texts)
|
||||
|
||||
|
||||
def test_home_panel_hides_referral_when_disabled() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
referral_enabled=False,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(section=PANEL_SECTION_HOME, context=context)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert all("Реферал" not in text for text in button_texts)
|
||||
|
||||
|
||||
def test_referral_panel_caption_when_disabled() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
referral_enabled=False,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_REFERRAL,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "отключена" in caption
|
||||
484
tests/test_payment_service.py
Normal file
484
tests/test_payment_service.py
Normal file
@@ -0,0 +1,484 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.models import PaymentOrder, ReferralBonus, ReferralInvite, TelegramUser
|
||||
from app.services.payment_service import (
|
||||
FULFILLED_PAYMENT_STATUS,
|
||||
PENDING_PAYMENT_STATUS,
|
||||
REFERRAL_BONUS_APPLIED_STATUS,
|
||||
REFERRAL_BONUS_PENDING_STATUS,
|
||||
REVIEW_PAYMENT_STATUS,
|
||||
PaymentService,
|
||||
)
|
||||
from app.services.remnawave_client import RemnawaveApiError
|
||||
from tests.helpers import make_remote_user
|
||||
|
||||
|
||||
class _StubSyncService:
|
||||
def __init__(self, *, cached_users_by_telegram: dict[int, list] | None = None) -> None:
|
||||
self.refreshed: list[int] = []
|
||||
self.cached_users_by_telegram = cached_users_by_telegram or {}
|
||||
|
||||
async def get_referral_summary(self, telegram_id: int):
|
||||
return type(
|
||||
"ReferralSummaryStub",
|
||||
(),
|
||||
{
|
||||
"applied_referral_code": "",
|
||||
"referral_code": "",
|
||||
"total_invited": 0,
|
||||
"recent_names": [],
|
||||
},
|
||||
)()
|
||||
|
||||
async def refresh_cached_users_for_telegram(self, *, telegram_id: int):
|
||||
self.refreshed.append(telegram_id)
|
||||
return []
|
||||
|
||||
async def get_cached_users_for_telegram(self, telegram_id: int):
|
||||
return list(self.cached_users_by_telegram.get(telegram_id, []))
|
||||
|
||||
|
||||
class _StubRemnawaveClient:
|
||||
def __init__(self, *, created_user, users_by_telegram_id: dict[int, list] | None = None) -> None:
|
||||
self.created_user = created_user
|
||||
self.created_bodies: list[dict[str, object]] = []
|
||||
self.updated_bodies: list[dict[str, object]] = []
|
||||
self.users_by_telegram_id = users_by_telegram_id or {}
|
||||
|
||||
async def get_user_by_uuid(self, user_uuid: str):
|
||||
raise RemnawaveApiError(status_code=404, message="not found")
|
||||
|
||||
async def get_user_by_username(self, username: str):
|
||||
raise RemnawaveApiError(status_code=404, message="not found")
|
||||
|
||||
async def get_users_by_telegram_id(self, telegram_id: int):
|
||||
return list(self.users_by_telegram_id.get(telegram_id, []))
|
||||
|
||||
async def create_user(self, body: dict[str, object]):
|
||||
self.created_bodies.append(body)
|
||||
return self.created_user
|
||||
|
||||
async def update_user(self, body: dict[str, object]):
|
||||
self.updated_bodies.append(body)
|
||||
raw_uuid = str(body.get("uuid") or "")
|
||||
raw_expire_at = str(body.get("expireAt") or "")
|
||||
if raw_expire_at.endswith("Z"):
|
||||
raw_expire_at = raw_expire_at.replace("Z", "+00:00")
|
||||
next_expire_at = datetime.fromisoformat(raw_expire_at) if raw_expire_at else None
|
||||
|
||||
for telegram_id, users in self.users_by_telegram_id.items():
|
||||
for index, user in enumerate(users):
|
||||
if str(user.uuid) != raw_uuid:
|
||||
continue
|
||||
updated_user = make_remote_user(
|
||||
user_uuid=str(user.uuid),
|
||||
user_id=user.id,
|
||||
short_uuid=user.short_uuid,
|
||||
username=user.username,
|
||||
telegram_id=telegram_id,
|
||||
expire_at=next_expire_at or user.expire_at,
|
||||
traffic_limit_bytes=user.traffic_limit_bytes,
|
||||
)
|
||||
users[index] = updated_user
|
||||
return updated_user
|
||||
|
||||
raise AssertionError("update_user should not be called in this scenario")
|
||||
|
||||
|
||||
async def _seed_order(
|
||||
session_factory,
|
||||
*,
|
||||
telegram_id: int,
|
||||
status: str,
|
||||
order_uuid: str | None = None,
|
||||
) -> str:
|
||||
resolved_order_uuid = order_uuid or str(uuid4())
|
||||
|
||||
async with session_factory() as session:
|
||||
user = TelegramUser(
|
||||
telegram_id=telegram_id,
|
||||
username="tester",
|
||||
first_name="Test",
|
||||
language_code="ru",
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
session.add(
|
||||
PaymentOrder(
|
||||
order_uuid=resolved_order_uuid,
|
||||
telegram_user_id=user.id,
|
||||
telegram_id=telegram_id,
|
||||
plan_code="30d",
|
||||
plan_title="OREOL VPN на 30 дней",
|
||||
plan_duration_days=30,
|
||||
traffic_limit_bytes=0,
|
||||
traffic_limit_strategy="NO_RESET",
|
||||
amount_stars=250,
|
||||
currency="RUB",
|
||||
status=status,
|
||||
invoice_payload=f"payload-{resolved_order_uuid}",
|
||||
provision_username="Oreol-1001-tester",
|
||||
error_message=None,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return resolved_order_uuid
|
||||
|
||||
|
||||
async def _seed_referral_invite(
|
||||
session_factory,
|
||||
*,
|
||||
inviter_telegram_id: int,
|
||||
invited_telegram_id: int,
|
||||
) -> None:
|
||||
async with session_factory() as session:
|
||||
session.add(
|
||||
ReferralInvite(
|
||||
inviter_telegram_id=inviter_telegram_id,
|
||||
invited_telegram_id=invited_telegram_id,
|
||||
invited_username="buyer",
|
||||
invited_display_name="Buyer",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _make_settings() -> Settings:
|
||||
return Settings.model_construct(
|
||||
bot_admin_ids_raw="",
|
||||
payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111",
|
||||
payment_user_tag="BOT",
|
||||
payment_external_squad_uuid="",
|
||||
payment_username_prefix="Oreol",
|
||||
payment_plan_traffic_limit_gb=0,
|
||||
payment_plan_traffic_reset_period="NO_RESET",
|
||||
referral_discount_percent=5,
|
||||
referral_bonus_days=7,
|
||||
)
|
||||
|
||||
|
||||
async def test_mark_order_under_review_only_transitions_once(session_factory) -> None:
|
||||
service = PaymentService(
|
||||
settings=_make_settings(),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=_StubRemnawaveClient(
|
||||
created_user=make_remote_user(
|
||||
user_uuid="11111111-1111-1111-1111-111111111111",
|
||||
user_id=1,
|
||||
short_uuid="abc123",
|
||||
username="oreol-user",
|
||||
telegram_id=1001,
|
||||
)
|
||||
),
|
||||
sync_service=_StubSyncService(),
|
||||
)
|
||||
order_uuid = await _seed_order(
|
||||
session_factory,
|
||||
telegram_id=1001,
|
||||
status=PENDING_PAYMENT_STATUS,
|
||||
)
|
||||
|
||||
order = await service.mark_order_under_review(order_uuid=order_uuid, telegram_id=1001)
|
||||
|
||||
assert order.status == REVIEW_PAYMENT_STATUS
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await service.mark_order_under_review(order_uuid=order_uuid, telegram_id=1001)
|
||||
|
||||
|
||||
async def test_approve_order_requires_review_status(session_factory) -> None:
|
||||
remote_user = make_remote_user(
|
||||
user_uuid="22222222-2222-2222-2222-222222222222",
|
||||
user_id=2,
|
||||
short_uuid="def456",
|
||||
username="oreol-user",
|
||||
telegram_id=1001,
|
||||
)
|
||||
service = PaymentService(
|
||||
settings=_make_settings(),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=_StubRemnawaveClient(created_user=remote_user),
|
||||
sync_service=_StubSyncService(),
|
||||
)
|
||||
order_uuid = await _seed_order(
|
||||
session_factory,
|
||||
telegram_id=1001,
|
||||
status=PENDING_PAYMENT_STATUS,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await service.approve_order(order_uuid=order_uuid)
|
||||
|
||||
|
||||
async def test_approve_order_fulfills_and_refreshes_cache(session_factory) -> None:
|
||||
remote_user = make_remote_user(
|
||||
user_uuid="33333333-3333-3333-3333-333333333333",
|
||||
user_id=3,
|
||||
short_uuid="ghi789",
|
||||
username="oreol-user",
|
||||
telegram_id=1001,
|
||||
)
|
||||
sync_service = _StubSyncService()
|
||||
remnawave_client = _StubRemnawaveClient(created_user=remote_user)
|
||||
service = PaymentService(
|
||||
settings=_make_settings(),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=remnawave_client,
|
||||
sync_service=sync_service,
|
||||
)
|
||||
order_uuid = await _seed_order(
|
||||
session_factory,
|
||||
telegram_id=1001,
|
||||
status=REVIEW_PAYMENT_STATUS,
|
||||
)
|
||||
|
||||
access = await service.approve_order(order_uuid=order_uuid)
|
||||
|
||||
assert access.remnawave_user_uuid == str(remote_user.uuid)
|
||||
assert access.is_renewal is False
|
||||
assert sync_service.refreshed == [1001]
|
||||
assert remnawave_client.created_bodies
|
||||
|
||||
async with session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
|
||||
)
|
||||
|
||||
assert order is not None
|
||||
assert order.status == FULFILLED_PAYMENT_STATUS
|
||||
assert order.remnawave_user_uuid == str(remote_user.uuid)
|
||||
|
||||
|
||||
async def test_approve_order_grants_and_applies_referral_bonus(session_factory) -> None:
|
||||
buyer_remote_user = make_remote_user(
|
||||
user_uuid="44444444-4444-4444-4444-444444444444",
|
||||
user_id=4,
|
||||
short_uuid="buyer444",
|
||||
username="oreol-buyer",
|
||||
telegram_id=1001,
|
||||
)
|
||||
inviter_remote_user = make_remote_user(
|
||||
user_uuid="55555555-5555-5555-5555-555555555555",
|
||||
user_id=5,
|
||||
short_uuid="inviter555",
|
||||
username="oreol-inviter",
|
||||
telegram_id=2002,
|
||||
)
|
||||
sync_service = _StubSyncService()
|
||||
remnawave_client = _StubRemnawaveClient(
|
||||
created_user=buyer_remote_user,
|
||||
users_by_telegram_id={2002: [inviter_remote_user]},
|
||||
)
|
||||
service = PaymentService(
|
||||
settings=_make_settings(),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=remnawave_client,
|
||||
sync_service=sync_service,
|
||||
)
|
||||
order_uuid = await _seed_order(
|
||||
session_factory,
|
||||
telegram_id=1001,
|
||||
status=REVIEW_PAYMENT_STATUS,
|
||||
)
|
||||
await _seed_referral_invite(
|
||||
session_factory,
|
||||
inviter_telegram_id=2002,
|
||||
invited_telegram_id=1001,
|
||||
)
|
||||
|
||||
access = await service.approve_order(order_uuid=order_uuid)
|
||||
|
||||
assert access.referral_bonus is not None
|
||||
assert access.referral_bonus.inviter_telegram_id == 2002
|
||||
assert access.referral_bonus.bonus_days == 7
|
||||
assert access.referral_bonus.status == REFERRAL_BONUS_APPLIED_STATUS
|
||||
assert sync_service.refreshed == [1001, 2002]
|
||||
assert any(
|
||||
body.get("uuid") == str(inviter_remote_user.uuid)
|
||||
for body in remnawave_client.updated_bodies
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
bonus = await session.scalar(
|
||||
select(ReferralBonus).where(ReferralBonus.source_order_uuid == order_uuid)
|
||||
)
|
||||
|
||||
assert bonus is not None
|
||||
assert bonus.status == REFERRAL_BONUS_APPLIED_STATUS
|
||||
|
||||
|
||||
async def test_approve_order_keeps_referral_bonus_pending_without_inviter_access(
|
||||
session_factory,
|
||||
) -> None:
|
||||
buyer_remote_user = make_remote_user(
|
||||
user_uuid="66666666-6666-6666-6666-666666666666",
|
||||
user_id=6,
|
||||
short_uuid="buyer666",
|
||||
username="oreol-buyer-2",
|
||||
telegram_id=1001,
|
||||
)
|
||||
sync_service = _StubSyncService()
|
||||
remnawave_client = _StubRemnawaveClient(created_user=buyer_remote_user)
|
||||
service = PaymentService(
|
||||
settings=_make_settings(),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=remnawave_client,
|
||||
sync_service=sync_service,
|
||||
)
|
||||
order_uuid = await _seed_order(
|
||||
session_factory,
|
||||
telegram_id=1001,
|
||||
status=REVIEW_PAYMENT_STATUS,
|
||||
)
|
||||
await _seed_referral_invite(
|
||||
session_factory,
|
||||
inviter_telegram_id=2002,
|
||||
invited_telegram_id=1001,
|
||||
)
|
||||
|
||||
access = await service.approve_order(order_uuid=order_uuid)
|
||||
|
||||
assert access.referral_bonus is not None
|
||||
assert access.referral_bonus.status == REFERRAL_BONUS_PENDING_STATUS
|
||||
assert sync_service.refreshed == [1001]
|
||||
|
||||
async with session_factory() as session:
|
||||
bonus = await session.scalar(
|
||||
select(ReferralBonus).where(ReferralBonus.source_order_uuid == order_uuid)
|
||||
)
|
||||
|
||||
assert bonus is not None
|
||||
assert bonus.status == REFERRAL_BONUS_PENDING_STATUS
|
||||
|
||||
|
||||
async def test_create_order_generates_unique_provision_username(session_factory) -> None:
|
||||
service = PaymentService(
|
||||
settings=Settings.model_construct(
|
||||
bot_admin_ids_raw="",
|
||||
payment_plans_raw="30:250",
|
||||
payment_transfer_text="Реквизиты",
|
||||
payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111",
|
||||
payment_user_tag="BOT",
|
||||
payment_external_squad_uuid="",
|
||||
payment_username_prefix="Oreol",
|
||||
payment_plan_traffic_limit_gb=0,
|
||||
payment_plan_traffic_reset_period="NO_RESET",
|
||||
referral_discount_percent=5,
|
||||
referral_bonus_days=7,
|
||||
),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=_StubRemnawaveClient(
|
||||
created_user=make_remote_user(
|
||||
user_uuid="77777777-7777-7777-7777-777777777777",
|
||||
user_id=7,
|
||||
short_uuid="buyer777",
|
||||
username="oreol-user",
|
||||
telegram_id=1001,
|
||||
)
|
||||
),
|
||||
sync_service=_StubSyncService(),
|
||||
)
|
||||
|
||||
first_order = await service.create_order(
|
||||
telegram_id=1001,
|
||||
username="tester",
|
||||
first_name="Test",
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
plan_code="30d",
|
||||
)
|
||||
second_order = await service.create_order(
|
||||
telegram_id=1001,
|
||||
username="tester",
|
||||
first_name="Test",
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
plan_code="30d",
|
||||
)
|
||||
|
||||
assert first_order.provision_username != second_order.provision_username
|
||||
assert first_order.extends_existing_access is False
|
||||
assert second_order.extends_existing_access is False
|
||||
|
||||
|
||||
async def test_create_order_marks_existing_access_as_renewal(session_factory) -> None:
|
||||
service = PaymentService(
|
||||
settings=Settings.model_construct(
|
||||
bot_admin_ids_raw="",
|
||||
payment_plans_raw="30:250",
|
||||
payment_transfer_text="Реквизиты",
|
||||
payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111",
|
||||
payment_user_tag="BOT",
|
||||
payment_external_squad_uuid="",
|
||||
payment_username_prefix="Oreol",
|
||||
payment_plan_traffic_limit_gb=0,
|
||||
payment_plan_traffic_reset_period="NO_RESET",
|
||||
referral_discount_percent=5,
|
||||
referral_bonus_days=7,
|
||||
),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=_StubRemnawaveClient(
|
||||
created_user=make_remote_user(
|
||||
user_uuid="77777777-7777-7777-7777-777777777777",
|
||||
user_id=7,
|
||||
short_uuid="buyer777",
|
||||
username="oreol-user",
|
||||
telegram_id=1001,
|
||||
)
|
||||
),
|
||||
sync_service=_StubSyncService(cached_users_by_telegram={1001: [object()]}),
|
||||
)
|
||||
|
||||
order = await service.create_order(
|
||||
telegram_id=1001,
|
||||
username="tester",
|
||||
first_name="Test",
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
plan_code="30d",
|
||||
)
|
||||
|
||||
assert order.extends_existing_access is True
|
||||
assert "продлит текущую подписку" in service.build_transfer_text(order=order)
|
||||
|
||||
|
||||
async def test_approve_order_extends_existing_user_found_by_telegram_id(session_factory) -> None:
|
||||
existing_remote_user = make_remote_user(
|
||||
user_uuid="88888888-8888-8888-8888-888888888888",
|
||||
user_id=8,
|
||||
short_uuid="existing888",
|
||||
username="oreol-existing",
|
||||
telegram_id=1001,
|
||||
)
|
||||
sync_service = _StubSyncService()
|
||||
remnawave_client = _StubRemnawaveClient(
|
||||
created_user=existing_remote_user,
|
||||
users_by_telegram_id={1001: [existing_remote_user]},
|
||||
)
|
||||
service = PaymentService(
|
||||
settings=_make_settings(),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=remnawave_client,
|
||||
sync_service=sync_service,
|
||||
)
|
||||
order_uuid = await _seed_order(
|
||||
session_factory,
|
||||
telegram_id=1001,
|
||||
status=REVIEW_PAYMENT_STATUS,
|
||||
)
|
||||
|
||||
access = await service.approve_order(order_uuid=order_uuid)
|
||||
|
||||
assert access.remnawave_user_uuid == str(existing_remote_user.uuid)
|
||||
assert access.is_renewal is True
|
||||
assert remnawave_client.created_bodies == []
|
||||
assert remnawave_client.updated_bodies
|
||||
59
tests/test_remnawave_client.py
Normal file
59
tests/test_remnawave_client.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import httpx
|
||||
|
||||
from app.services.remnawave_client import RemnawaveApiClient
|
||||
|
||||
|
||||
def test_normalize_base_url_adds_api_suffix() -> None:
|
||||
assert (
|
||||
RemnawaveApiClient.normalize_base_url("https://panel.example.com")
|
||||
== "https://panel.example.com/api/"
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_base_url_keeps_existing_api_suffix() -> None:
|
||||
assert (
|
||||
RemnawaveApiClient.normalize_base_url("https://panel.example.com/api/")
|
||||
== "https://panel.example.com/api/"
|
||||
)
|
||||
|
||||
|
||||
def test_unwrap_payload_response_field() -> None:
|
||||
payload = {"response": {"ok": True}}
|
||||
assert RemnawaveApiClient.unwrap_payload(payload) == {"ok": True}
|
||||
|
||||
|
||||
def test_build_error_includes_error_code() -> None:
|
||||
client = object.__new__(RemnawaveApiClient)
|
||||
response = httpx.Response(
|
||||
500,
|
||||
json={
|
||||
"message": "Failed to create user",
|
||||
"errorCode": "A018",
|
||||
},
|
||||
request=httpx.Request("POST", "https://panel.example.com/api/users"),
|
||||
)
|
||||
|
||||
error = client._build_error(response)
|
||||
|
||||
assert error.message == "Failed to create user [A018]"
|
||||
|
||||
|
||||
def test_build_error_includes_validation_details() -> None:
|
||||
client = object.__new__(RemnawaveApiClient)
|
||||
response = httpx.Response(
|
||||
400,
|
||||
json={
|
||||
"message": "Validation failed",
|
||||
"errors": [
|
||||
{
|
||||
"message": "Invalid uuid",
|
||||
"path": ["uuid"],
|
||||
}
|
||||
],
|
||||
},
|
||||
request=httpx.Request("DELETE", "https://panel.example.com/api/users/not-a-uuid"),
|
||||
)
|
||||
|
||||
error = client._build_error(response)
|
||||
|
||||
assert error.message == "Validation failed | uuid: Invalid uuid"
|
||||
208
tests/test_sync_service.py
Normal file
208
tests/test_sync_service.py
Normal file
@@ -0,0 +1,208 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.models import ReferralCode, ReferralInvite, RemnawaveUser, TelegramUser
|
||||
from app.services.sync_service import SyncService
|
||||
from tests.helpers import make_remote_user
|
||||
|
||||
|
||||
class _StubRemnawaveClient:
|
||||
def __init__(self, users) -> None:
|
||||
self.users = users
|
||||
|
||||
async def get_users_by_telegram_id(self, telegram_id: int):
|
||||
return list(self.users)
|
||||
|
||||
|
||||
async def test_refresh_cached_users_clears_stale_links_and_keeps_profile(
|
||||
session_factory,
|
||||
) -> None:
|
||||
user_one = make_remote_user(
|
||||
user_uuid="44444444-4444-4444-4444-444444444444",
|
||||
user_id=4,
|
||||
short_uuid="user-one",
|
||||
username="oreol-one",
|
||||
telegram_id=1001,
|
||||
)
|
||||
user_two = make_remote_user(
|
||||
user_uuid="55555555-5555-5555-5555-555555555555",
|
||||
user_id=5,
|
||||
short_uuid="user-two",
|
||||
username="oreol-two",
|
||||
telegram_id=1001,
|
||||
)
|
||||
client = _StubRemnawaveClient([user_one, user_two])
|
||||
service = SyncService(
|
||||
settings=Settings.model_construct(
|
||||
bot_admin_ids_raw="",
|
||||
sync_subscription_history=False,
|
||||
),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=client,
|
||||
)
|
||||
|
||||
await service.sync_users_for_telegram(
|
||||
telegram_id=1001,
|
||||
username="keeper",
|
||||
first_name="Keep",
|
||||
last_name="Me",
|
||||
language_code="ru",
|
||||
)
|
||||
|
||||
client.users = [user_one]
|
||||
views = await service.refresh_cached_users_for_telegram(telegram_id=1001)
|
||||
|
||||
assert [view.record.rw_uuid for view in views] == [str(user_one.uuid)]
|
||||
|
||||
async with session_factory() as session:
|
||||
telegram_user = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == 1001)
|
||||
)
|
||||
stale_user = await session.scalar(
|
||||
select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(user_two.uuid))
|
||||
)
|
||||
|
||||
assert telegram_user is not None
|
||||
assert telegram_user.username == "keeper"
|
||||
assert stale_user is not None
|
||||
assert stale_user.telegram_id is None
|
||||
assert stale_user.owner_telegram_user_id is None
|
||||
|
||||
|
||||
async def test_get_admin_telegram_users_page_returns_paginated_users(
|
||||
session_factory,
|
||||
) -> None:
|
||||
service = SyncService(
|
||||
settings=Settings.model_construct(
|
||||
bot_admin_ids_raw="",
|
||||
sync_subscription_history=False,
|
||||
),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=_StubRemnawaveClient([]),
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add_all(
|
||||
[
|
||||
TelegramUser(
|
||||
telegram_id=1001,
|
||||
username="first",
|
||||
first_name="First",
|
||||
last_name="User",
|
||||
language_code="ru",
|
||||
last_seen_at=datetime(2026, 4, 21, 10, 0),
|
||||
created_at=datetime(2026, 4, 1, 10, 0),
|
||||
updated_at=datetime(2026, 4, 21, 10, 0),
|
||||
),
|
||||
TelegramUser(
|
||||
telegram_id=1002,
|
||||
username="second",
|
||||
first_name="Second",
|
||||
last_name="User",
|
||||
language_code="ru",
|
||||
last_seen_at=datetime(2026, 4, 22, 12, 0),
|
||||
created_at=datetime(2026, 4, 2, 10, 0),
|
||||
updated_at=datetime(2026, 4, 22, 12, 0),
|
||||
),
|
||||
TelegramUser(
|
||||
telegram_id=1003,
|
||||
username="third",
|
||||
first_name="Third",
|
||||
last_name="User",
|
||||
language_code="ru",
|
||||
last_seen_at=datetime(2026, 4, 22, 12, 0),
|
||||
created_at=datetime(2026, 4, 3, 10, 0),
|
||||
updated_at=datetime(2026, 4, 22, 12, 0),
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
page_one = await service.get_admin_telegram_users_page(page=1, page_size=2)
|
||||
page_two = await service.get_admin_telegram_users_page(page=2, page_size=2)
|
||||
|
||||
assert page_one.total_items == 3
|
||||
assert page_one.total_pages == 2
|
||||
assert [item.telegram_id for item in page_one.items] == [1003, 1002]
|
||||
assert [item.telegram_id for item in page_two.items] == [1001]
|
||||
|
||||
|
||||
async def test_get_admin_telegram_user_profile_returns_referral_and_access_data(
|
||||
session_factory,
|
||||
) -> None:
|
||||
remote_user = make_remote_user(
|
||||
user_uuid="66666666-6666-6666-6666-666666666666",
|
||||
user_id=6,
|
||||
short_uuid="profile-user",
|
||||
username="oreol-profile",
|
||||
telegram_id=2001,
|
||||
)
|
||||
service = SyncService(
|
||||
settings=Settings.model_construct(
|
||||
bot_admin_ids_raw="1001",
|
||||
sync_subscription_history=False,
|
||||
),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=_StubRemnawaveClient([remote_user]),
|
||||
)
|
||||
|
||||
await service.sync_users_for_telegram(
|
||||
telegram_id=2001,
|
||||
username="profile",
|
||||
first_name="Profile",
|
||||
last_name="User",
|
||||
language_code="ru",
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(
|
||||
TelegramUser(
|
||||
telegram_id=1001,
|
||||
username="inviter",
|
||||
first_name="Inviter",
|
||||
last_name="Admin",
|
||||
language_code="ru",
|
||||
is_admin=True,
|
||||
last_seen_at=datetime(2026, 4, 22, 11, 0),
|
||||
created_at=datetime(2026, 4, 1, 8, 0),
|
||||
updated_at=datetime(2026, 4, 22, 11, 0),
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
ReferralCode(telegram_id=1001, code="INVITER1001")
|
||||
)
|
||||
session.add(
|
||||
ReferralCode(telegram_id=2001, code="SELF2001")
|
||||
)
|
||||
session.add_all(
|
||||
[
|
||||
ReferralInvite(
|
||||
inviter_telegram_id=1001,
|
||||
invited_telegram_id=2001,
|
||||
invited_username="profile",
|
||||
invited_display_name="Profile User",
|
||||
),
|
||||
ReferralInvite(
|
||||
inviter_telegram_id=2001,
|
||||
invited_telegram_id=3001,
|
||||
invited_username="child",
|
||||
invited_display_name="Child User",
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
profile = await service.get_admin_telegram_user_profile(telegram_id=2001)
|
||||
|
||||
assert profile is not None
|
||||
assert profile.user.telegram_id == 2001
|
||||
assert profile.referral_code == "SELF2001"
|
||||
assert profile.applied_referral_code == "INVITER1001"
|
||||
assert profile.invited_count == 1
|
||||
assert profile.recent_invited_names == ["Child User"]
|
||||
assert len(profile.accesses) == 1
|
||||
assert profile.accesses[0].record.username == "oreol-profile"
|
||||
Reference in New Issue
Block a user