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

13
.dockerignore Normal file
View 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

View File

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

@@ -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/

View File

@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"WebFetch(github.com)"
]
}
}

12
Dockerfile Normal file
View 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
View File

@@ -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`
- открытие раздела подписки через кнопку в панели
- ввод реферального кода
- отправку чека
- подтверждение оплаты модератором
- выдачу ссылки пользователю
- поддержку через тикет

View File

@@ -1 +0,0 @@
# OreolRP Subscription Bot

1
app/__init__.py Normal file
View File

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

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

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

View File

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

2499
app/bot/handlers/main.py Normal file

File diff suppressed because it is too large Load Diff

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

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

595
app/bot/ui/panel.py Normal file
View File

@@ -0,0 +1,595 @@
from __future__ import annotations
import html
import math
from dataclasses import dataclass
from datetime import datetime
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from app.services.sync_service import CachedUserView
from app.utils.formatters import format_bytes, format_datetime, format_payment_plan, format_traffic_limit
PANEL_SECTION_HOME = "home"
PANEL_SECTION_ADMIN = "admin"
PANEL_SECTION_SUBSCRIPTION = "subscription"
PANEL_SECTION_REFERRAL = "referral"
PANEL_SECTION_SUPPORT = "support"
PANEL_SECTION_SUPPORT_TICKET = "support_ticket"
PANEL_SECTION_TERMS = "terms"
@dataclass(slots=True)
class PanelContext:
telegram_id: int
display_name: str
username: str | None
language_code: str | None
is_admin: bool
users: list[CachedUserView]
referral_enabled: bool = True
referral_code: str = ""
referral_count: int = 0
applied_referral_code: str = ""
referral_link: str = ""
referral_share_url: str = ""
referral_recent_names: list[str] | None = None
referral_discount_percent: int = 0
referral_bonus_days: int = 0
admin_total_telegram_users: int = 0
admin_total_cached_users: int = 0
admin_active_cached_users: int = 0
admin_pending_orders: int = 0
admin_review_orders: int = 0
admin_open_tickets: int = 0
admin_pending_referral_bonuses: int = 0
payment_enabled: bool = False
payment_plan_title: str = ""
payment_plan_description: str = ""
payment_plan_price_stars: int = 0
payment_plan_duration_days: int = 0
payment_plan_traffic_limit_bytes: int = 0
api_error: str | None = None
def _callback(section: str, *, refresh: bool = False) -> str:
return f"panel:{section}:refresh" if refresh else f"panel:{section}"
def _ticket_callback(action: str) -> str:
return f"ticket:{action}"
def _referral_callback(action: str) -> str:
return f"referral:{action}"
def _payment_callback(action: str) -> str:
return f"payment:{action}"
def _admin_callback(action: str) -> str:
return f"admin:{action}"
def _status_badge(status: str) -> str:
mapping = {
"ACTIVE": "🟢 Активна",
"LIMITED": "🟡 Ограничена",
"DISABLED": "⛔ Отключена",
"EXPIRED": "🔴 Истекла",
}
return mapping.get(status.upper(), f"{html.escape(status)}")
def _active_users_count(users: list[CachedUserView]) -> int:
return sum(1 for user in users if user.record.status.upper() == "ACTIVE")
def _primary_user(users: list[CachedUserView]) -> CachedUserView | None:
for user in users:
if user.record.status.upper() == "ACTIVE":
return user
return users[0] if users else None
def _home_subscription_status(users: list[CachedUserView]) -> str:
return "Активна" if _active_users_count(users) > 0 else "Не активна"
def _days_left_text(user: CachedUserView | None) -> str:
if user is None or user.record.expire_at is None:
return ""
seconds_left = (user.record.expire_at - datetime.now()).total_seconds()
if seconds_left <= 0:
return "0 дней"
days_left = math.ceil(seconds_left / 86400)
return f"{days_left} дней"
def _traffic_summary_text(user: CachedUserView | None) -> str:
if user is None:
return ""
return (
f"{format_bytes(user.record.used_traffic_bytes)} / "
f"{format_traffic_limit(user.record.traffic_limit_bytes)}"
)
def build_panel_keyboard(
*,
section: str,
context: PanelContext,
support_url: str = "",
terms_url: str = "",
payment_review_url: str = "",
support_ticket_url: str = "",
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
primary_user = _primary_user(context.users)
if section == PANEL_SECTION_HOME:
has_sub_link = bool(
primary_user
and primary_user.record.subscription_url.startswith(("http://", "https://"))
)
if has_sub_link:
builder.button(
text="🔗 Открыть подписку",
url=primary_user.record.subscription_url,
)
builder.button(
text="💳 Подписка",
callback_data=_payment_callback("menu"),
)
if context.is_admin:
builder.button(
text="🛠 Админ-панель",
callback_data=_callback(PANEL_SECTION_ADMIN),
)
if context.referral_enabled:
builder.button(
text="👥 Реферальная система",
callback_data=_callback(PANEL_SECTION_REFERRAL),
)
builder.button(
text="🆘 Поддержка",
callback_data=_callback(PANEL_SECTION_SUPPORT),
)
if terms_url:
builder.button(text="📜 Условия использования", url=terms_url)
else:
builder.button(
text="📜 Условия использования",
callback_data=_callback(PANEL_SECTION_TERMS),
)
if has_sub_link:
if context.is_admin and context.referral_enabled:
builder.adjust(1, 1, 1, 2, 1)
elif context.is_admin:
builder.adjust(1, 1, 1, 1, 1)
else:
if context.referral_enabled:
builder.adjust(1, 1, 2, 1)
else:
builder.adjust(1, 1, 1, 1)
else:
if context.is_admin and context.referral_enabled:
builder.adjust(1, 1, 2, 1)
elif context.is_admin:
builder.adjust(1, 1, 1, 1)
else:
if context.referral_enabled:
builder.adjust(1, 2, 1)
else:
builder.adjust(1, 1, 1)
return builder.as_markup()
if section == PANEL_SECTION_ADMIN:
if not context.is_admin:
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
builder.adjust(1)
return builder.as_markup()
builder.button(
text="🔍 Найти пользователя",
callback_data=_admin_callback("lookup"),
)
builder.button(
text="👤 Пользователи",
callback_data=_admin_callback("users"),
)
builder.button(
text="🔄 Полная синхронизация",
callback_data=_admin_callback("sync_all"),
)
builder.button(
text="⚙️ Настройки",
callback_data=_admin_callback("settings"),
)
if payment_review_url.strip():
builder.button(text="💸 Очередь оплат", url=payment_review_url.strip())
if support_ticket_url.strip():
builder.button(text="🎫 Тикеты", url=support_ticket_url.strip())
builder.button(
text="↻ Обновить",
callback_data=_callback(PANEL_SECTION_ADMIN, refresh=True),
)
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
builder.adjust(2, 2, 2, 2)
return builder.as_markup()
if section == PANEL_SECTION_SUBSCRIPTION:
if context.payment_enabled:
builder.button(
text="💳 Продлить подписку" if context.users else "💳 Купить подписку",
callback_data=_payment_callback("buy"),
)
if primary_user and primary_user.record.subscription_url.startswith(("http://", "https://")):
builder.button(
text="🔗 Открыть подписку",
url=primary_user.record.subscription_url,
)
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
builder.adjust(1)
return builder.as_markup()
if section == PANEL_SECTION_REFERRAL:
if not context.referral_enabled:
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
builder.adjust(1)
return builder.as_markup()
if context.referral_share_url:
builder.button(
text="📨 Поделиться ссылкой",
url=context.referral_share_url,
)
builder.button(
text="🎟 Ввести реферальный код",
callback_data=_referral_callback("apply"),
)
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
builder.adjust(1)
return builder.as_markup()
if section == PANEL_SECTION_SUPPORT:
if support_url.strip():
builder.button(text="Написать в поддержку", url=support_url.strip())
builder.button(
text="🎫 Создать тикет",
callback_data=_ticket_callback("create"),
)
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
builder.adjust(1)
return builder.as_markup()
if section == PANEL_SECTION_SUPPORT_TICKET:
builder.button(
text="🎫 Создать тикет",
callback_data=_ticket_callback("create"),
)
builder.button(text="🆘 Поддержка", callback_data=_callback(PANEL_SECTION_SUPPORT))
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
builder.adjust(1, 2)
return builder.as_markup()
if section == PANEL_SECTION_TERMS:
if terms_url:
builder.button(text="📜 Открыть Terms of Use", url=terms_url)
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
builder.adjust(1)
return builder.as_markup()
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
return builder.as_markup()
def render_panel_caption(*, section: str, context: PanelContext, brand_name: str) -> str:
if section == PANEL_SECTION_ADMIN:
return _render_admin_caption(context=context, brand_name=brand_name)
if section == PANEL_SECTION_SUBSCRIPTION:
return _render_subscription_caption(context=context)
if section == PANEL_SECTION_REFERRAL:
return _render_referral_caption(context=context)
if section == PANEL_SECTION_SUPPORT:
return _render_support_caption()
if section == PANEL_SECTION_SUPPORT_TICKET:
return _render_support_ticket_caption()
if section == PANEL_SECTION_TERMS:
return _render_terms_caption()
return _render_home_caption(context=context, brand_name=brand_name)
def _render_home_caption(*, context: PanelContext, brand_name: str) -> str:
primary_user = _primary_user(context.users)
sub_lines = [
f"Статус: <b>{_home_subscription_status(context.users)}</b>",
f"Срок: <b>{_days_left_text(primary_user)}</b>",
f"Трафик: <b>{_traffic_summary_text(primary_user)}</b>",
]
if primary_user and primary_user.record.subscription_url:
sub_lines.append(f"Ссылка: <code>{html.escape(primary_user.record.subscription_url)}</code>")
lines = [
f"Добро пожаловать в <b>{html.escape(brand_name)}</b>",
"",
"<b>Подписка</b>",
"<blockquote>" + "\n".join(sub_lines) + "</blockquote>",
]
if context.api_error:
lines.extend(
[
"",
"<i>Remnawave временно недоступен. Показаны данные из локального кэша.</i>",
]
)
if context.is_admin:
lines.extend(
[
"",
"<i>Для управления ботом используйте кнопку «Админ-панель» ниже.</i>",
]
)
return "\n".join(lines)
def _render_admin_caption(*, context: PanelContext, brand_name: str) -> str:
if not context.is_admin:
return "\n".join(
[
"<b>🛠 Админ-панель</b>",
"",
"<blockquote>Недостаточно прав для доступа к этому разделу.</blockquote>",
]
)
return "\n".join(
[
f"<b>🛠 Админ-панель {html.escape(brand_name)}</b>",
"",
(
"<blockquote>"
f"Пользователей в боте: <b>{context.admin_total_telegram_users}</b>\n"
f"Доступов в кэше: <b>{context.admin_total_cached_users}</b>\n"
f"Активных доступов: <b>{context.admin_active_cached_users}</b>\n"
f"Заказов без чека: <b>{context.admin_pending_orders}</b>\n"
f"Чеков на проверке: <b>{context.admin_review_orders}</b>\n"
f"Открытых тикетов: <b>{context.admin_open_tickets}</b>\n"
f"Бонусов в ожидании: <b>{context.admin_pending_referral_bonuses}</b>"
"</blockquote>"
),
"",
"<i>Используйте кнопки ниже для поиска пользователей, синхронизации и перехода в рабочие чаты.</i>",
]
)
def _render_subscription_caption(*, context: PanelContext) -> str:
lines = ["<b>💳 Подписка</b>"]
if not context.users:
if context.payment_enabled:
lines.extend(
[
"",
"<blockquote>У вас пока нет активного доступа.</blockquote>",
"",
format_payment_plan(
title=context.payment_plan_title,
description=context.payment_plan_description,
price_stars=context.payment_plan_price_stars,
duration_days=context.payment_plan_duration_days,
traffic_limit_bytes=context.payment_plan_traffic_limit_bytes,
),
"",
(
"<i>После выбора тарифа бот покажет реквизиты. "
"Затем нужно отправить чек, а администратор подтвердит оплату и выдаст доступ.</i>"
),
]
)
else:
lines.extend(
[
(
"<blockquote>"
"У вас пока нет активного доступа.\n\n"
"Если у вас уже есть подписка, откройте поддержку "
"и отправьте short UUID из ссылки подписки."
"</blockquote>"
),
"",
"<i>Покупка пока не настроена в .env. Нужны параметры PAYMENT_*.</i>",
]
)
return "\n".join(lines)
lines.append("<b>Ваши доступы</b>")
for index, user in enumerate(context.users[:2], start=1):
squad_text = ", ".join(user.internal_squads[:2]) if user.internal_squads else "не назначены"
if len(squad_text) > 80:
squad_text = f"{squad_text[:77]}..."
lines.append(
(
f"{index}. <b>{html.escape(user.record.username)}</b> — "
f"{_status_badge(user.record.status)}\n"
"<blockquote>"
f"Истекает: <b>{format_datetime(user.record.expire_at)}</b>\n"
f"Трафик: <b>{format_bytes(user.record.used_traffic_bytes)}</b> / "
f"<b>{format_traffic_limit(user.record.traffic_limit_bytes)}</b>\n"
f"Short UUID: <code>{html.escape(user.record.short_uuid)}</code>\n"
f"Группы: {html.escape(squad_text)}"
"</blockquote>"
)
)
if len(context.users) > 2:
lines.append(f"<i>И ещё аккаунтов: {len(context.users) - 2}</i>")
if context.payment_enabled:
lines.extend(
[
"",
(
"<i>"
"Чтобы продлить текущую подписку, используйте кнопку "
"«Продлить подписку». После подтверждения оплаты бот "
"автоматически добавит выбранный срок к вашему доступу."
"</i>"
),
]
)
if context.api_error:
lines.append("")
lines.append("<i>Последнее обновление пришло из кэша.</i>")
return "\n".join(lines)
def _render_referral_caption(*, context: PanelContext) -> str:
if not context.referral_enabled:
return "\n".join(
[
"<b>👥 Реферальная система</b>",
"",
"<blockquote>Реферальная система сейчас отключена администратором.</blockquote>",
]
)
lines = [
"<b>👥 Реферальная система</b>",
(
"<blockquote>"
f"Ваш реферальный код: <code>{html.escape(context.referral_code or '')}</code>\n"
f"Приглашено пользователей: <b>{context.referral_count}</b>"
"</blockquote>"
),
]
if context.referral_bonus_days > 0:
lines.extend(
[
"",
(
"<blockquote>"
f"🎁 Бонус за подтверждённую оплату по вашему коду: "
f"<b>+{context.referral_bonus_days} дней</b>"
"</blockquote>"
),
]
)
if context.applied_referral_code:
lines.extend(
[
"",
"<b>Активированная скидка</b>",
(
"<blockquote>"
f"Код: <code>{html.escape(context.applied_referral_code)}</code>\n"
f"Скидка перед оплатой: <b>{max(context.referral_discount_percent, 0)}%</b>"
"</blockquote>"
),
]
)
else:
lines.extend(
[
"",
(
"<i>"
f"Чтобы получить скидку {max(context.referral_discount_percent, 0)}%, "
"код нужно ввести вручную до оплаты."
"</i>"
),
]
)
if context.referral_link:
lines.extend(
[
"",
"<b>Ваша ссылка</b>",
f"<blockquote><code>{html.escape(context.referral_link)}</code></blockquote>",
]
)
else:
lines.extend(
[
"",
"<i>Для генерации ссылки укажите BOT_PUBLIC_USERNAME в .env.</i>",
]
)
if context.referral_recent_names:
lines.extend(
[
"",
"<b>Последние приглашения</b>",
"<blockquote>" + "\n".join(html.escape(name) for name in context.referral_recent_names) + "</blockquote>",
]
)
lines.extend(
[
"",
"<i>Переход по ссылке сам по себе не активирует скидку. Код нужно ввести вручную перед оплатой.</i>",
]
)
return "\n".join(lines)
def _render_terms_caption() -> str:
return "\n".join(
[
"<b>📜 Правила VPN</b>",
(
"<blockquote>"
"1. Используя сервис и бота, вы подтверждаете согласие с правилами платформы и обязуетесь использовать VPN только в законных целях.\n"
"2. VPN предназначен для защиты данных, приватности и безопасного доступа к интернет-ресурсам.\n"
"3. Запрещены передача доступа третьим лицам, спам, взлом, мошенничество, распространение вредоносного ПО, атаки и любой незаконный контент.\n"
"4. Пользователь самостоятельно несёт ответственность за свои действия при использовании сервиса.\n"
"5. Для стабильной работы могут обрабатываться технические данные подключения без передачи третьим лицам, кроме случаев, предусмотренных законодательством.\n"
"6. При нарушении правил доступ может быть ограничен или приостановлен без возврата средств.\n\n"
"⚠️ VPN повышает уровень конфиденциальности, но не гарантирует полной анонимности."
"</blockquote>"
),
]
)
def _render_support_caption() -> str:
return "\n".join(
[
"<b>🆘 Помощь и сопровождение</b>",
(
"<blockquote>"
"Здесь вы можете обратиться в поддержку по вопросам доступа, оплаты, подключения и работы сервиса.\n\n"
"Если нужна помощь, откройте тикет через кнопку ниже."
"</blockquote>"
),
]
)
def _render_support_ticket_caption() -> str:
return "\n".join(
[
"<b>🎫 Создание тикета</b>",
(
"<blockquote>"
"Опишите проблему как можно подробнее: устройство, приложение, текст ошибки и что именно не работает.\n\n"
"После нажатия кнопки ниже бот переведёт вас в режим создания тикета, и следующее сообщение будет отправлено в поддержку."
"</blockquote>"
),
]
)

305
app/config.py Normal file
View File

@@ -0,0 +1,305 @@
from __future__ import annotations
from dataclasses import dataclass
from functools import cached_property
from urllib.parse import quote_plus, urlparse
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@dataclass(frozen=True, slots=True)
class PaymentPlanSettings:
code: str
days: int
amount_rub: int
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
bot_token: str = Field(validation_alias="BOT_TOKEN")
bot_admin_ids_raw: str = Field(default="", validation_alias="BOT_ADMIN_IDS")
bot_moderator_ids_raw: str = Field(default="", validation_alias="BOT_MODERATOR_IDS")
telegram_proxy_url: str = Field(default="", validation_alias="TELEGRAM_PROXY_URL")
bot_brand_name: str = Field(default="OREOL VPN", validation_alias="BOT_BRAND_NAME")
bot_public_username: str = Field(default="", validation_alias="BOT_PUBLIC_USERNAME")
bot_support_url: str = Field(default="", validation_alias="BOT_SUPPORT_URL")
bot_terms_url: str = Field(default="", validation_alias="BOT_TERMS_URL")
bot_support_ticket_link: str = Field(default="", validation_alias="BOT_SUPPORT_TICKET_LINK")
bot_support_ticket_chat_id_raw: str = Field(
default="",
validation_alias="BOT_SUPPORT_TICKET_CHAT_ID",
)
bot_support_ticket_thread_id: int = Field(
default=0,
validation_alias="BOT_SUPPORT_TICKET_THREAD_ID",
)
bot_start_image_enabled: bool = Field(
default=True,
validation_alias="BOT_START_IMAGE_ENABLED",
)
bot_start_image_path: str = Field(default="", validation_alias="BOT_START_IMAGE_PATH")
remnawave_base_url: str = Field(validation_alias="REMNAWAVE_BASE_URL")
remnawave_api_token: str = Field(default="", validation_alias="REMNAWAVE_API_TOKEN")
remnawave_caddy_api_key: str = Field(default="", validation_alias="REMNAWAVE_CADDY_API_KEY")
remnawave_timeout_seconds: float = Field(
default=20.0,
validation_alias="REMNAWAVE_TIMEOUT_SECONDS",
)
db_host: str = Field(default="127.0.0.1", validation_alias="DB_HOST")
db_port: int = Field(default=3306, validation_alias="DB_PORT")
db_name: str = Field(default="telegabot", validation_alias="DB_NAME")
db_user: str = Field(default="telegabot", validation_alias="DB_USER")
db_password: str = Field(default="", validation_alias="DB_PASSWORD")
db_echo: bool = Field(default=False, validation_alias="DB_ECHO")
create_database_on_start: bool = Field(
default=True,
validation_alias="CREATE_DATABASE_ON_START",
)
create_tables_on_start: bool = Field(
default=True,
validation_alias="CREATE_TABLES_ON_START",
)
sync_subscription_history: bool = Field(
default=True,
validation_alias="SYNC_SUBSCRIPTION_HISTORY",
)
sync_batch_size: int = Field(default=100, validation_alias="SYNC_BATCH_SIZE")
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
payment_plans_raw: str = Field(
default="30:250,180:600,365:1000",
validation_alias="PAYMENT_PLANS",
)
payment_transfer_text: str = Field(
default="Переведите оплату по указанным реквизитам и отправьте чек в бот.",
validation_alias="PAYMENT_TRANSFER_TEXT",
)
payment_review_link: str = Field(default="", validation_alias="PAYMENT_REVIEW_LINK")
payment_review_chat_id_raw: str = Field(
default="",
validation_alias="PAYMENT_REVIEW_CHAT_ID",
)
payment_review_thread_id: int = Field(
default=0,
validation_alias="PAYMENT_REVIEW_THREAD_ID",
)
referral_discount_percent: int = Field(
default=5,
validation_alias="REFERRAL_DISCOUNT_PERCENT",
)
referral_bonus_days: int = Field(
default=7,
validation_alias="REFERRAL_BONUS_DAYS",
)
payment_plan_code: str = Field(default="vpn_30d", validation_alias="PAYMENT_PLAN_CODE")
payment_plan_title: str = Field(
default="OREOL VPN на 30 дней",
validation_alias="PAYMENT_PLAN_TITLE",
)
payment_plan_description: str = Field(
default="Доступ к VPN на 30 дней",
validation_alias="PAYMENT_PLAN_DESCRIPTION",
)
payment_plan_price_stars: int = Field(default=500, validation_alias="PAYMENT_PLAN_PRICE_STARS")
payment_plan_duration_days: int = Field(
default=30,
validation_alias="PAYMENT_PLAN_DURATION_DAYS",
)
payment_plan_traffic_limit_gb: int = Field(
default=0,
validation_alias="PAYMENT_PLAN_TRAFFIC_LIMIT_GB",
)
payment_plan_traffic_reset_period: str = Field(
default="NO_RESET",
validation_alias="PAYMENT_PLAN_TRAFFIC_RESET_PERIOD",
)
payment_internal_squad_uuids_raw: str = Field(
default="",
validation_alias="PAYMENT_INTERNAL_SQUAD_UUIDS",
)
payment_external_squad_uuid: str = Field(
default="",
validation_alias="PAYMENT_EXTERNAL_SQUAD_UUID",
)
payment_support_text: str = Field(
default="Если оплата прошла, но доступ не выдался, напишите в поддержку.",
validation_alias="PAYMENT_SUPPORT_TEXT",
)
payment_username_prefix: str = Field(
default="Oreol",
validation_alias="PAYMENT_USERNAME_PREFIX",
)
payment_user_tag: str = Field(default="BOT", validation_alias="PAYMENT_USER_TAG")
@cached_property
def bot_admin_ids(self) -> set[int]:
values: set[int] = set()
for raw_part in self.bot_admin_ids_raw.split(","):
part = raw_part.strip()
if part:
values.add(int(part))
return values
@cached_property
def bot_moderator_ids(self) -> set[int]:
values: set[int] = set()
for raw_part in self.bot_moderator_ids_raw.split(","):
part = raw_part.strip()
if part:
values.add(int(part))
return values
@property
def database_url(self) -> str:
user = quote_plus(self.db_user)
password = quote_plus(self.db_password)
return (
f"mysql+aiomysql://{user}:{password}"
f"@{self.db_host}:{self.db_port}/{self.db_name}?charset=utf8mb4"
)
@property
def database_server_url(self) -> str:
user = quote_plus(self.db_user)
password = quote_plus(self.db_password)
return (
f"mysql+aiomysql://{user}:{password}"
f"@{self.db_host}:{self.db_port}/?charset=utf8mb4"
)
@cached_property
def support_ticket_chat_id(self) -> int | None:
if self.bot_support_ticket_chat_id_raw.strip():
return int(self.bot_support_ticket_chat_id_raw.strip())
parsed_chat_id, _ = self._parse_private_topic_link(self.bot_support_ticket_link)
return parsed_chat_id
@cached_property
def support_ticket_message_thread_id(self) -> int | None:
if self.bot_support_ticket_thread_id > 0:
return self.bot_support_ticket_thread_id
_, parsed_thread_id = self._parse_private_topic_link(self.bot_support_ticket_link)
return parsed_thread_id
@cached_property
def payment_review_chat_id(self) -> int | None:
if self.payment_review_chat_id_raw.strip():
return int(self.payment_review_chat_id_raw.strip())
parsed_chat_id, _ = self._parse_private_topic_link(self.payment_review_link)
return parsed_chat_id
@cached_property
def payment_review_message_thread_id(self) -> int | None:
if self.payment_review_thread_id > 0:
return self.payment_review_thread_id
_, parsed_thread_id = self._parse_private_topic_link(self.payment_review_link)
return parsed_thread_id
@staticmethod
def _parse_private_topic_link(link: str) -> tuple[int | None, int | None]:
cleaned = link.strip()
if not cleaned:
return None, None
parsed = urlparse(cleaned)
if parsed.netloc not in {"t.me", "telegram.me", "www.t.me", "www.telegram.me"}:
return None, None
parts = [part for part in parsed.path.split("/") if part]
if len(parts) < 3 or parts[0] != "c" or not parts[1].isdigit():
return None, None
chat_id = int(f"-100{parts[1]}")
thread_id = int(parts[2]) if len(parts) >= 4 and parts[2].isdigit() else None
return chat_id, thread_id
def is_admin(self, telegram_id: int | None) -> bool:
return telegram_id is not None and telegram_id in self.bot_admin_ids
def is_moderator(self, telegram_id: int | None) -> bool:
return telegram_id is not None and telegram_id in self.bot_moderator_ids
@property
def bot_public_username_normalized(self) -> str:
return self.bot_public_username.strip().removeprefix("@")
@cached_property
def payment_internal_squad_uuids(self) -> list[str]:
values: list[str] = []
for raw_part in self.payment_internal_squad_uuids_raw.split(","):
part = raw_part.strip()
if part:
values.append(part)
return values
@cached_property
def payment_external_squad_uuid_normalized(self) -> str | None:
value = self.payment_external_squad_uuid.strip()
return value or None
@cached_property
def payment_username_prefix_normalized(self) -> str:
normalized = "".join(
ch for ch in self.payment_username_prefix.strip() if ch.isalnum() or ch in "_-"
)
return normalized[:12] or "Oreol"
@cached_property
def payment_user_tag_normalized(self) -> str | None:
normalized = "".join(
ch for ch in self.payment_user_tag.strip().upper() if ch.isalnum() or ch == "_"
)
return normalized[:16] or None
@cached_property
def payment_plans(self) -> list[PaymentPlanSettings]:
values: list[PaymentPlanSettings] = []
for raw_part in self.payment_plans_raw.split(","):
part = raw_part.strip()
if not part:
continue
fragments = [fragment.strip() for fragment in part.split(":")]
if len(fragments) != 2 or not fragments[0].isdigit() or not fragments[1].isdigit():
continue
days = int(fragments[0])
amount_rub = int(fragments[1])
if days <= 0 or amount_rub <= 0:
continue
values.append(
PaymentPlanSettings(
code=f"{days}d",
days=days,
amount_rub=amount_rub,
)
)
if values:
return sorted(values, key=lambda item: item.days)
fallback_amount = max(self.payment_plan_price_stars, 0)
fallback_days = max(self.payment_plan_duration_days, 0)
if fallback_amount > 0 and fallback_days > 0:
return [
PaymentPlanSettings(
code=self.payment_plan_code.strip() or f"{fallback_days}d",
days=fallback_days,
amount_rub=fallback_amount,
)
]
return []

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

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

11
app/db/base.py Normal file
View File

@@ -0,0 +1,11 @@
from datetime import datetime, timezone
from sqlalchemy.orm import DeclarativeBase
def utcnow() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
class Base(DeclarativeBase):
"""Base class for ORM models."""

238
app/db/models.py Normal file
View File

@@ -0,0 +1,238 @@
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, utcnow
class TelegramUser(Base):
__tablename__ = "telegram_users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
first_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
language_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
last_seen_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
class RemnawaveUser(Base):
__tablename__ = "remnawave_users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
owner_telegram_user_id: Mapped[int | None] = mapped_column(
ForeignKey("telegram_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
rw_uuid: Mapped[str] = mapped_column(String(36), nullable=False, unique=True, index=True)
rw_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True)
short_uuid: Mapped[str] = mapped_column(String(48), nullable=False, unique=True, index=True)
username: Mapped[str] = mapped_column(String(36), nullable=False, unique=True, index=True)
status: Mapped[str] = mapped_column(String(16), nullable=False)
traffic_limit_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
used_traffic_bytes: Mapped[float] = mapped_column(Float(asdecimal=False), nullable=False, default=0.0)
lifetime_used_traffic_bytes: Mapped[float] = mapped_column(Float(asdecimal=False), nullable=False, default=0.0)
expire_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
telegram_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
email: Mapped[str | None] = mapped_column(String(320), nullable=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
tag: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
hwid_device_limit: Mapped[int | None] = mapped_column(Integer, nullable=True)
external_squad_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True)
trojan_password: Mapped[str] = mapped_column(String(64), nullable=False)
vless_uuid: Mapped[str] = mapped_column(String(36), nullable=False)
ss_password: Mapped[str] = mapped_column(String(64), nullable=False)
last_triggered_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
sub_revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
sub_last_user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
sub_last_opened_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_traffic_reset_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
subscription_url: Mapped[str] = mapped_column(Text, nullable=False)
online_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
first_connected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_connected_node_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True)
remote_created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
remote_updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
synced_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
class InternalSquad(Base):
__tablename__ = "internal_squads"
uuid: Mapped[str] = mapped_column(String(36), primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
synced_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
class RemnawaveUserInternalSquad(Base):
__tablename__ = "remnawave_user_internal_squads"
user_id: Mapped[int] = mapped_column(
ForeignKey("remnawave_users.id", ondelete="CASCADE"),
primary_key=True,
)
squad_uuid: Mapped[str] = mapped_column(
ForeignKey("internal_squads.uuid", ondelete="CASCADE"),
primary_key=True,
)
class SubscriptionRequestLog(Base):
__tablename__ = "subscription_request_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
remote_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
user_uuid: Mapped[str] = mapped_column(
ForeignKey("remnawave_users.rw_uuid", ondelete="CASCADE"),
nullable=False,
index=True,
)
request_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
request_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
synced_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
class SupportTicket(Base):
__tablename__ = "support_tickets"
__table_args__ = (
Index(
"ix_support_tickets_chat_message",
"support_chat_id",
"support_message_id",
unique=True,
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
public_id: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, index=True)
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
user_message: Mapped[str] = mapped_column(Text, nullable=False)
support_chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
support_thread_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
support_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="OPEN")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
class ReferralInvite(Base):
__tablename__ = "referral_invites"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
inviter_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
invited_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
invited_username: Mapped[str | None] = mapped_column(String(64), nullable=True)
invited_display_name: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
class ReferralCode(Base):
__tablename__ = "referral_codes"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
code: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
class ReferralBonus(Base):
__tablename__ = "referral_bonuses"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
inviter_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
invited_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
source_order_uuid: Mapped[str] = mapped_column(String(36), nullable=False, unique=True, index=True)
bonus_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="PENDING", index=True)
applied_to_user_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
applied_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
class BotSetting(Base):
__tablename__ = "bot_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
value: Mapped[str] = mapped_column(Text, nullable=False, default="")
updated_by_telegram_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
class SubscriptionNotification(Base):
__tablename__ = "subscription_notifications"
__table_args__ = (
Index(
"ix_sub_notif_unique",
"remnawave_user_id",
"notification_type",
"expire_at_snapshot",
unique=True,
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
remnawave_user_id: Mapped[int] = mapped_column(
ForeignKey("remnawave_users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
notification_type: Mapped[str] = mapped_column(String(32), nullable=False)
expire_at_snapshot: Mapped[datetime] = mapped_column(DateTime, nullable=False)
sent_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
class PaymentOrder(Base):
__tablename__ = "payment_orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
order_uuid: Mapped[str] = mapped_column(String(36), nullable=False, unique=True, index=True)
telegram_user_id: Mapped[int] = mapped_column(
ForeignKey("telegram_users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
plan_code: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
plan_title: Mapped[str] = mapped_column(String(255), nullable=False)
plan_duration_days: Mapped[int] = mapped_column(Integer, nullable=False)
traffic_limit_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
traffic_limit_strategy: Mapped[str] = mapped_column(String(32), nullable=False, default="NO_RESET")
amount_stars: Mapped[int] = mapped_column(Integer, nullable=False)
currency: Mapped[str] = mapped_column(String(8), nullable=False, default="XTR")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="PENDING", index=True)
invoice_payload: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
provision_username: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
remnawave_user_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
subscription_url: Mapped[str | None] = mapped_column(Text, nullable=True)
telegram_payment_charge_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
provider_payment_charge_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
paid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
fulfilled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)

152
app/db/session.py Normal file
View File

@@ -0,0 +1,152 @@
import logging
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
AsyncConnection,
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.db.base import Base
from app.db import models as _models # noqa: F401
logger = logging.getLogger(__name__)
PAYMENT_ORDERS_TABLE_NAME = "payment_orders"
PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN = "provision_username"
PAYMENT_ORDERS_PROVISION_USERNAME_INDEX = "ix_payment_orders_provision_username"
def create_engine_and_session_factory(
database_url: str,
*,
echo: bool = False,
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
engine = create_async_engine(
database_url,
echo=echo,
pool_pre_ping=True,
pool_recycle=3600,
)
session_factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
return engine, session_factory
def _quote_mysql_identifier(identifier: str) -> str:
return f"`{identifier.replace('`', '``')}`"
async def _get_mysql_single_column_indexes(
connection: AsyncConnection,
*,
table_name: str,
column_name: str,
non_unique: int,
) -> list[str]:
result = await connection.execute(
text(
"""
SELECT index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = :table_name
GROUP BY index_name, non_unique
HAVING non_unique = :non_unique
AND COUNT(*) = 1
AND MAX(CASE WHEN column_name = :column_name THEN 1 ELSE 0 END) = 1
"""
),
{
"table_name": table_name,
"column_name": column_name,
"non_unique": non_unique,
},
)
return [row[0] for row in result]
async def _normalize_payment_order_provision_username_index(
connection: AsyncConnection,
) -> None:
if connection.dialect.name != "mysql":
return
unique_index_names = await _get_mysql_single_column_indexes(
connection,
table_name=PAYMENT_ORDERS_TABLE_NAME,
column_name=PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN,
non_unique=0,
)
quoted_table_name = _quote_mysql_identifier(PAYMENT_ORDERS_TABLE_NAME)
for index_name in unique_index_names:
logger.info(
"Dropping stale UNIQUE index %s on %s.%s",
index_name,
PAYMENT_ORDERS_TABLE_NAME,
PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN,
)
await connection.execute(
text(
"DROP INDEX "
f"{_quote_mysql_identifier(index_name)} "
f"ON {quoted_table_name}"
)
)
non_unique_index_names = await _get_mysql_single_column_indexes(
connection,
table_name=PAYMENT_ORDERS_TABLE_NAME,
column_name=PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN,
non_unique=1,
)
if non_unique_index_names:
return
logger.info(
"Creating non-unique index %s on %s.%s",
PAYMENT_ORDERS_PROVISION_USERNAME_INDEX,
PAYMENT_ORDERS_TABLE_NAME,
PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN,
)
await connection.execute(
text(
"CREATE INDEX "
f"{_quote_mysql_identifier(PAYMENT_ORDERS_PROVISION_USERNAME_INDEX)} "
f"ON {quoted_table_name} "
f"({_quote_mysql_identifier(PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN)})"
)
)
async def ensure_database_exists(
server_url: str,
database_name: str,
*,
echo: bool = False,
) -> None:
engine = create_async_engine(
server_url,
echo=echo,
pool_pre_ping=True,
pool_recycle=3600,
)
try:
async with engine.begin() as connection:
await connection.execute(
text(
"CREATE DATABASE IF NOT EXISTS "
f"{_quote_mysql_identifier(database_name)} "
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
)
finally:
await engine.dispose()
async def init_db(engine: AsyncEngine) -> None:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await _normalize_payment_order_provision_username_index(connection)

135
app/main.py Normal file
View File

@@ -0,0 +1,135 @@
import asyncio
import logging
from pathlib import Path
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramNetworkError
from aiogram.types import BotCommand
from app.bot.handlers.main import build_router
from app.config import Settings
from app.db.session import create_engine_and_session_factory, ensure_database_exists, init_db
from app.services.bot_config_service import BotConfigService
from app.services.notification_service import SubscriptionNotificationService
from app.services.payment_service import PaymentService
from app.services.remnawave_client import RemnawaveApiClient
from app.services.support_ticket_service import SupportTicketService
from app.services.sync_service import SyncService
async def main() -> None:
settings = Settings()
logging.basicConfig(
level=getattr(logging, settings.log_level.upper(), logging.INFO),
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger(__name__)
engine = None
remnawave_client = None
bot = None
try:
if settings.create_database_on_start:
logger.info(
"Ensuring MariaDB database '%s' exists on %s:%s",
settings.db_name,
settings.db_host,
settings.db_port,
)
await ensure_database_exists(
settings.database_server_url,
settings.db_name,
echo=settings.db_echo,
)
engine, session_factory = create_engine_and_session_factory(
settings.database_url,
echo=settings.db_echo,
)
if settings.create_tables_on_start:
await init_db(engine)
remnawave_client = RemnawaveApiClient(
base_url=settings.remnawave_base_url,
api_token=settings.remnawave_api_token,
caddy_api_key=settings.remnawave_caddy_api_key,
timeout_seconds=settings.remnawave_timeout_seconds,
)
sync_service = SyncService(
settings=settings,
session_factory=session_factory,
remnawave_client=remnawave_client,
)
bot_config_service = BotConfigService(
settings=settings,
session_factory=session_factory,
)
support_ticket_service = SupportTicketService(session_factory=session_factory)
payment_service = PaymentService(
settings=settings,
session_factory=session_factory,
remnawave_client=remnawave_client,
sync_service=sync_service,
config_service=bot_config_service,
)
bot_session = AiohttpSession(proxy=settings.telegram_proxy_url or None)
bot = Bot(
token=settings.bot_token,
session=bot_session,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
dispatcher = Dispatcher()
dispatcher.include_router(
build_router(
settings=settings,
sync_service=sync_service,
support_ticket_service=support_ticket_service,
payment_service=payment_service,
bot_config_service=bot_config_service,
)
)
await bot.set_my_commands(
[
BotCommand(command="start", description="Открыть главное меню"),
]
)
project_root = Path(__file__).resolve().parent.parent
notification_service = SubscriptionNotificationService(
session_factory=session_factory,
sync_service=sync_service,
config_service=bot_config_service,
project_root=project_root,
)
notification_task = asyncio.create_task(
notification_service.run_periodic(bot),
)
try:
await dispatcher.start_polling(bot)
finally:
notification_task.cancel()
try:
await notification_task
except asyncio.CancelledError:
pass
except TelegramNetworkError:
logger.exception(
"Cannot reach Telegram API. Check outbound HTTPS access to api.telegram.org:443, DNS, firewall, VPN, or proxy."
)
raise
finally:
if remnawave_client is not None:
await remnawave_client.close()
if bot is not None:
await bot.session.close()
if engine is not None:
await engine.dispose()

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

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

75
app/schemas/remnawave.py Normal file
View File

@@ -0,0 +1,75 @@
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class RemnawaveBaseModel(BaseModel):
model_config = ConfigDict(populate_by_name=True, extra="ignore")
class ActiveInternalSquad(RemnawaveBaseModel):
uuid: UUID
name: str
class UserTraffic(RemnawaveBaseModel):
used_traffic_bytes: float = Field(alias="usedTrafficBytes")
lifetime_used_traffic_bytes: float = Field(alias="lifetimeUsedTrafficBytes")
online_at: datetime | None = Field(default=None, alias="onlineAt")
first_connected_at: datetime | None = Field(default=None, alias="firstConnectedAt")
last_connected_node_uuid: UUID | None = Field(default=None, alias="lastConnectedNodeUuid")
class RemnawaveUser(RemnawaveBaseModel):
uuid: UUID
id: int
short_uuid: str = Field(alias="shortUuid")
username: str
status: str
traffic_limit_bytes: int = Field(alias="trafficLimitBytes")
expire_at: datetime = Field(alias="expireAt")
telegram_id: int | None = Field(default=None, alias="telegramId")
email: str | None = None
description: str | None = None
tag: str | None = None
hwid_device_limit: int | None = Field(default=None, alias="hwidDeviceLimit")
external_squad_uuid: UUID | None = Field(default=None, alias="externalSquadUuid")
trojan_password: str = Field(alias="trojanPassword")
vless_uuid: UUID = Field(alias="vlessUuid")
ss_password: str = Field(alias="ssPassword")
last_triggered_threshold: int = Field(alias="lastTriggeredThreshold")
sub_revoked_at: datetime | None = Field(default=None, alias="subRevokedAt")
sub_last_user_agent: str | None = Field(default=None, alias="subLastUserAgent")
sub_last_opened_at: datetime | None = Field(default=None, alias="subLastOpenedAt")
last_traffic_reset_at: datetime | None = Field(default=None, alias="lastTrafficResetAt")
created_at: datetime = Field(alias="createdAt")
updated_at: datetime = Field(alias="updatedAt")
subscription_url: str = Field(alias="subscriptionUrl")
active_internal_squads: list[ActiveInternalSquad] = Field(alias="activeInternalSquads")
user_traffic: UserTraffic = Field(alias="userTraffic")
class PaginatedUsers(RemnawaveBaseModel):
users: list[RemnawaveUser]
total: int
class ResolvedUser(RemnawaveBaseModel):
uuid: UUID
username: str
id: int
short_uuid: str = Field(alias="shortUuid")
class SubscriptionRequestRecord(RemnawaveBaseModel):
id: int
user_uuid: UUID = Field(alias="userUuid")
request_at: datetime = Field(alias="requestAt")
request_ip: str | None = Field(default=None, alias="requestIp")
user_agent: str | None = Field(default=None, alias="userAgent")
class SubscriptionRequestHistory(RemnawaveBaseModel):
total: int
records: list[SubscriptionRequestRecord]

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

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

View File

@@ -0,0 +1,744 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import PaymentPlanSettings, Settings
from app.db.base import utcnow
from app.db.models import BotSetting
@dataclass(frozen=True, slots=True)
class BotConfigFieldSpec:
key: str
label: str
section: str
prompt: str
description: str
placeholder: str = ""
is_secret_like: bool = False
@dataclass(slots=True)
class BotConfigSnapshot:
bot_brand_name: str
bot_public_username: str
bot_support_url: str
bot_terms_url: str
bot_support_ticket_link: str
payment_review_link: str
bot_start_image_enabled: bool
bot_start_image_path: str
referral_enabled: bool
referral_discount_percent: int
referral_bonus_days: int
payment_plans_raw: str
payment_transfer_text: str
payment_support_text: str
payment_plan_traffic_limit_gb: int
payment_plan_traffic_reset_period: str
payment_internal_squad_uuids_raw: str
payment_external_squad_uuid: str
payment_username_prefix: str
payment_user_tag: str
notification_enabled: bool = True
notification_days_before: str = "1,3"
notification_check_interval_hours: int = 6
fallback_support_ticket_chat_id: int | None = None
fallback_support_ticket_thread_id: int | None = None
fallback_payment_review_chat_id: int | None = None
fallback_payment_review_thread_id: int | None = None
@property
def bot_public_username_normalized(self) -> str:
return self.bot_public_username.strip().removeprefix("@")
@property
def payment_external_squad_uuid_normalized(self) -> str | None:
value = self.payment_external_squad_uuid.strip()
return value or None
@property
def payment_username_prefix_normalized(self) -> str:
normalized = "".join(
ch for ch in self.payment_username_prefix.strip() if ch.isalnum() or ch in "_-"
)
return normalized[:12] or "Oreol"
@property
def payment_user_tag_normalized(self) -> str | None:
normalized = "".join(
ch for ch in self.payment_user_tag.strip().upper() if ch.isalnum() or ch == "_"
)
return normalized[:16] or None
@property
def payment_internal_squad_uuids(self) -> list[str]:
values: list[str] = []
for raw_part in self.payment_internal_squad_uuids_raw.split(","):
part = raw_part.strip()
if part:
values.append(part)
return values
@property
def notification_days_list(self) -> list[int]:
values: list[int] = []
for raw_part in self.notification_days_before.split(","):
part = raw_part.strip()
if part.isdigit():
day = int(part)
if day > 0:
values.append(day)
return sorted(set(values), reverse=True) if values else [1, 3]
@property
def payment_plans(self) -> list[PaymentPlanSettings]:
values: list[PaymentPlanSettings] = []
for raw_part in self.payment_plans_raw.split(","):
part = raw_part.strip()
if not part:
continue
fragments = [fragment.strip() for fragment in part.split(":")]
if len(fragments) != 2 or not fragments[0].isdigit() or not fragments[1].isdigit():
continue
days = int(fragments[0])
amount_rub = int(fragments[1])
if days <= 0 or amount_rub <= 0:
continue
values.append(
PaymentPlanSettings(
code=f"{days}d",
days=days,
amount_rub=amount_rub,
)
)
return sorted(values, key=lambda item: item.days)
@staticmethod
def _compact_text(raw_value: str, *, max_length: int = 80) -> str:
normalized = " ".join(raw_value.split()).strip()
if not normalized:
return "-"
if len(normalized) <= max_length:
return normalized
return f"{normalized[: max_length - 3]}..."
@property
def support_ticket_chat_id(self) -> int | None:
parsed_chat_id, _ = Settings._parse_private_topic_link(self.bot_support_ticket_link)
return parsed_chat_id if parsed_chat_id is not None else self.fallback_support_ticket_chat_id
@property
def support_ticket_message_thread_id(self) -> int | None:
_, parsed_thread_id = Settings._parse_private_topic_link(self.bot_support_ticket_link)
return (
parsed_thread_id
if parsed_thread_id is not None
else self.fallback_support_ticket_thread_id
)
@property
def payment_review_chat_id(self) -> int | None:
parsed_chat_id, _ = Settings._parse_private_topic_link(self.payment_review_link)
return parsed_chat_id if parsed_chat_id is not None else self.fallback_payment_review_chat_id
@property
def payment_review_message_thread_id(self) -> int | None:
_, parsed_thread_id = Settings._parse_private_topic_link(self.payment_review_link)
return (
parsed_thread_id
if parsed_thread_id is not None
else self.fallback_payment_review_thread_id
)
class StaticBotConfigService:
def __init__(self, settings: Settings) -> None:
self._settings = settings
async def get_snapshot(self) -> BotConfigSnapshot:
return BotConfigSnapshot(
bot_brand_name=self._settings.bot_brand_name,
bot_public_username=self._settings.bot_public_username,
bot_support_url=self._settings.bot_support_url,
bot_terms_url=self._settings.bot_terms_url,
bot_support_ticket_link=self._settings.bot_support_ticket_link,
payment_review_link=self._settings.payment_review_link,
bot_start_image_enabled=self._settings.bot_start_image_enabled,
bot_start_image_path=self._settings.bot_start_image_path,
referral_enabled=True,
referral_discount_percent=self._settings.referral_discount_percent,
referral_bonus_days=self._settings.referral_bonus_days,
payment_plans_raw=self._settings.payment_plans_raw,
payment_transfer_text=self._settings.payment_transfer_text,
payment_support_text=self._settings.payment_support_text,
payment_plan_traffic_limit_gb=self._settings.payment_plan_traffic_limit_gb,
payment_plan_traffic_reset_period=self._settings.payment_plan_traffic_reset_period,
notification_enabled=True,
notification_days_before="1,3",
notification_check_interval_hours=6,
payment_internal_squad_uuids_raw=self._settings.payment_internal_squad_uuids_raw,
payment_external_squad_uuid=self._settings.payment_external_squad_uuid,
payment_username_prefix=self._settings.payment_username_prefix,
payment_user_tag=self._settings.payment_user_tag,
fallback_support_ticket_chat_id=self._settings.support_ticket_chat_id,
fallback_support_ticket_thread_id=self._settings.support_ticket_message_thread_id,
fallback_payment_review_chat_id=self._settings.payment_review_chat_id,
fallback_payment_review_thread_id=self._settings.payment_review_message_thread_id,
)
class BotConfigService(StaticBotConfigService):
FIELD_SPECS: dict[str, BotConfigFieldSpec] = {
"bot_brand_name": BotConfigFieldSpec(
key="bot_brand_name",
label="Название бренда",
section="brand",
prompt="Введите новое название бренда.",
description="Используется в главной панели и пользовательских сообщениях.",
placeholder="OREOL VPN",
),
"bot_public_username": BotConfigFieldSpec(
key="bot_public_username",
label="Username бота",
section="brand",
prompt="Введите username бота без @. Для очистки отправьте `-`.",
description="Нужен для генерации реферальных ссылок.",
placeholder="oreol_vpn_bot",
),
"bot_start_image_enabled": BotConfigFieldSpec(
key="bot_start_image_enabled",
label="Стартовая картинка",
section="brand",
prompt="Введите `on` или `off`.",
description="Включает или выключает бренд-картинку в панели и выдаче доступа.",
placeholder="on",
),
"bot_start_image_path": BotConfigFieldSpec(
key="bot_start_image_path",
label="Путь к картинке",
section="brand",
prompt="Введите путь к изображению. Для очистки отправьте `-`.",
description="Можно указать относительный путь от корня проекта.",
placeholder="assets/main.png",
),
"payment_plans_raw": BotConfigFieldSpec(
key="payment_plans_raw",
label="Тарифы и цены",
section="pricing",
prompt="Введите тарифы в формате `30:250,180:600,365:1000`.",
description="Каждая пара — это `дни:цена_в_рублях`.",
placeholder="30:250,180:600,365:1000",
),
"payment_plan_traffic_limit_gb": BotConfigFieldSpec(
key="payment_plan_traffic_limit_gb",
label="Лимит трафика, GB",
section="pricing",
prompt="Введите лимит трафика в гигабайтах. `0` = безлимит.",
description="Применяется к тарифам, создаваемым ботом.",
placeholder="0",
),
"payment_plan_traffic_reset_period": BotConfigFieldSpec(
key="payment_plan_traffic_reset_period",
label="Стратегия сброса трафика",
section="pricing",
prompt="Введите стратегию, например `NO_RESET`.",
description="Значение передаётся в Remnawave.",
placeholder="NO_RESET",
),
"referral_enabled": BotConfigFieldSpec(
key="referral_enabled",
label="Реферальная система",
section="referral",
prompt="Введите `on` или `off`.",
description="Включает или выключает ввод рефкода и начисление реферальных бонусов.",
placeholder="on",
),
"referral_discount_percent": BotConfigFieldSpec(
key="referral_discount_percent",
label="Скидка по рефкоду, %",
section="referral",
prompt="Введите размер скидки в процентах от 0 до 100.",
description="Применяется к покупателю перед оплатой.",
placeholder="5",
),
"referral_bonus_days": BotConfigFieldSpec(
key="referral_bonus_days",
label="Бонус рефереру, дней",
section="referral",
prompt="Введите количество бонусных дней. `0` отключает бонус.",
description="Начисляется после подтверждённой оплаты по рефкоду.",
placeholder="7",
),
"bot_support_url": BotConfigFieldSpec(
key="bot_support_url",
label="Ссылка на поддержку",
section="links",
prompt="Введите URL поддержки. Для очистки отправьте `-`.",
description="Кнопка быстрой связи с поддержкой в пользовательской панели.",
placeholder="https://t.me/your_support_account",
),
"bot_terms_url": BotConfigFieldSpec(
key="bot_terms_url",
label="Ссылка на Terms",
section="links",
prompt="Введите URL правил использования. Для очистки отправьте `-`.",
description="Открывается из раздела условий использования.",
placeholder="https://example.com/terms",
),
"bot_support_ticket_link": BotConfigFieldSpec(
key="bot_support_ticket_link",
label="Ссылка на чат тикетов",
section="links",
prompt="Введите приватную ссылку на чат/топик тикетов. Для очистки отправьте `-`.",
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
placeholder="https://t.me/c/1234567890/2/3",
),
"payment_review_link": BotConfigFieldSpec(
key="payment_review_link",
label="Ссылка на очередь оплат",
section="links",
prompt="Введите приватную ссылку на чат/топик проверки оплат. Для очистки отправьте `-`.",
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
placeholder="https://t.me/c/1234567890/56/57",
),
"payment_transfer_text": BotConfigFieldSpec(
key="payment_transfer_text",
label="Инструкция по оплате",
section="texts",
prompt="Введите текст с реквизитами или инструкцией перевода.",
description="Показывается пользователю после выбора тарифа.",
placeholder="Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN",
),
"payment_support_text": BotConfigFieldSpec(
key="payment_support_text",
label="Текст поддержки по оплате",
section="texts",
prompt="Введите текст, который бот покажет при проблемах с оплатой.",
description="Используется при отклонении оплаты и ошибках выдачи доступа.",
placeholder="Если оплата прошла, но доступ не выдался, напишите в поддержку.",
),
"payment_internal_squad_uuids_raw": BotConfigFieldSpec(
key="payment_internal_squad_uuids_raw",
label="UUID внутренних групп",
section="provisioning",
prompt="Введите UUID внутренних групп через запятую.",
description="Эти группы назначаются пользователю в Remnawave.",
placeholder="11111111-1111-1111-1111-111111111111",
),
"payment_external_squad_uuid": BotConfigFieldSpec(
key="payment_external_squad_uuid",
label="UUID внешней группы",
section="provisioning",
prompt="Введите UUID внешней группы. Для очистки отправьте `-`.",
description="Необязательный внешний squad для новых пользователей.",
placeholder="11111111-1111-1111-1111-111111111111",
),
"payment_username_prefix": BotConfigFieldSpec(
key="payment_username_prefix",
label="Префикс логина",
section="provisioning",
prompt="Введите префикс логина для новых пользователей Remnawave.",
description="Итоговый логин строится как `<prefix>-<telegram_id>-<name>`.",
placeholder="Oreol",
),
"payment_user_tag": BotConfigFieldSpec(
key="payment_user_tag",
label="Tag пользователей",
section="provisioning",
prompt="Введите tag для создаваемых пользователей. Для очистки отправьте `-`.",
description="Опциональный tag в Remnawave.",
placeholder="BOT",
),
"notification_enabled": BotConfigFieldSpec(
key="notification_enabled",
label="Уведомления об истечении",
section="notifications",
prompt="Введите `on` или `off`.",
description="Включает или выключает уведомления о скором истечении подписки.",
placeholder="on",
),
"notification_days_before": BotConfigFieldSpec(
key="notification_days_before",
label="Дни до истечения",
section="notifications",
prompt="Введите дни через запятую, например `1,3`.",
description="За сколько дней до истечения отправлять уведомления.",
placeholder="1,3",
),
"notification_check_interval_hours": BotConfigFieldSpec(
key="notification_check_interval_hours",
label="Интервал проверки, ч",
section="notifications",
prompt="Введите интервал проверки в часах.",
description="Как часто бот проверяет подписки на истечение.",
placeholder="6",
),
}
SECTION_LABELS: dict[str, str] = {
"brand": "Бренд",
"pricing": "Тарифы",
"referral": "Рефералка",
"links": "Ссылки",
"texts": "Тексты",
"provisioning": "Provisioning",
"notifications": "Уведомления",
}
def __init__(
self,
*,
settings: Settings,
session_factory: async_sessionmaker[AsyncSession],
) -> None:
super().__init__(settings)
self._session_factory = session_factory
async def get_snapshot(self) -> BotConfigSnapshot:
overrides = await self._get_overrides()
return BotConfigSnapshot(
bot_brand_name=str(overrides.get("bot_brand_name", self._settings.bot_brand_name)),
bot_public_username=str(overrides.get("bot_public_username", self._settings.bot_public_username)),
bot_support_url=str(overrides.get("bot_support_url", self._settings.bot_support_url)),
bot_terms_url=str(overrides.get("bot_terms_url", self._settings.bot_terms_url)),
bot_support_ticket_link=str(overrides.get("bot_support_ticket_link", self._settings.bot_support_ticket_link)),
payment_review_link=str(overrides.get("payment_review_link", self._settings.payment_review_link)),
bot_start_image_enabled=self._parse_bool(
overrides.get("bot_start_image_enabled"),
default=self._settings.bot_start_image_enabled,
),
bot_start_image_path=str(overrides.get("bot_start_image_path", self._settings.bot_start_image_path)),
referral_enabled=self._parse_bool(
overrides.get("referral_enabled"),
default=True,
),
referral_discount_percent=self._parse_int(
overrides.get("referral_discount_percent"),
default=self._settings.referral_discount_percent,
),
referral_bonus_days=self._parse_int(
overrides.get("referral_bonus_days"),
default=self._settings.referral_bonus_days,
),
payment_plans_raw=str(overrides.get("payment_plans_raw", self._settings.payment_plans_raw)),
payment_transfer_text=str(overrides.get("payment_transfer_text", self._settings.payment_transfer_text)),
payment_support_text=str(overrides.get("payment_support_text", self._settings.payment_support_text)),
payment_plan_traffic_limit_gb=self._parse_int(
overrides.get("payment_plan_traffic_limit_gb"),
default=self._settings.payment_plan_traffic_limit_gb,
),
payment_plan_traffic_reset_period=str(
overrides.get(
"payment_plan_traffic_reset_period",
self._settings.payment_plan_traffic_reset_period,
)
),
payment_internal_squad_uuids_raw=str(
overrides.get(
"payment_internal_squad_uuids_raw",
self._settings.payment_internal_squad_uuids_raw,
)
),
payment_external_squad_uuid=str(
overrides.get(
"payment_external_squad_uuid",
self._settings.payment_external_squad_uuid,
)
),
payment_username_prefix=str(
overrides.get("payment_username_prefix", self._settings.payment_username_prefix)
),
payment_user_tag=str(overrides.get("payment_user_tag", self._settings.payment_user_tag)),
notification_enabled=self._parse_bool(
overrides.get("notification_enabled"),
default=True,
),
notification_days_before=str(overrides.get("notification_days_before", "1,3")),
notification_check_interval_hours=self._parse_int(
overrides.get("notification_check_interval_hours"),
default=6,
),
fallback_support_ticket_chat_id=self._settings.support_ticket_chat_id,
fallback_support_ticket_thread_id=self._settings.support_ticket_message_thread_id,
fallback_payment_review_chat_id=self._settings.payment_review_chat_id,
fallback_payment_review_thread_id=self._settings.payment_review_message_thread_id,
)
async def update_setting(
self,
*,
key: str,
raw_value: str,
updated_by_telegram_id: int,
) -> str:
spec = self.FIELD_SPECS.get(key)
if spec is None:
raise ValueError("Неизвестная настройка.")
normalized_value = self._normalize_value(key, raw_value)
async with self._session_factory() as session:
record = await session.scalar(
select(BotSetting).where(BotSetting.key == key)
)
if record is None:
record = BotSetting(key=key)
session.add(record)
record.value = normalized_value
record.updated_by_telegram_id = updated_by_telegram_id
record.updated_at = utcnow()
await session.commit()
return normalized_value
@classmethod
def get_spec(cls, key: str) -> BotConfigFieldSpec | None:
return cls.FIELD_SPECS.get(key)
@classmethod
def get_specs_for_section(cls, section: str) -> list[BotConfigFieldSpec]:
return [spec for spec in cls.FIELD_SPECS.values() if spec.section == section]
@classmethod
def get_sections(cls) -> list[tuple[str, str]]:
return list(cls.SECTION_LABELS.items())
@classmethod
def get_section_label(cls, section: str) -> str:
return cls.SECTION_LABELS.get(section, section)
@classmethod
def format_value(cls, *, snapshot: BotConfigSnapshot, key: str) -> str:
value = getattr(snapshot, key, "")
if key in {"bot_start_image_enabled", "referral_enabled", "notification_enabled"}:
return "on" if bool(value) else "off"
if key == "bot_public_username":
normalized = snapshot.bot_public_username_normalized
return f"@{normalized}" if normalized else "-"
if key in {
"bot_support_url",
"bot_terms_url",
"bot_support_ticket_link",
"payment_review_link",
"bot_start_image_path",
"payment_external_squad_uuid",
"payment_user_tag",
"payment_username_prefix",
"payment_internal_squad_uuids_raw",
"payment_plans_raw",
"payment_plan_traffic_reset_period",
"bot_brand_name",
"notification_days_before",
}:
return cls._compact_text(str(value))
if key == "payment_plan_traffic_limit_gb":
amount = int(value or 0)
return "0 GB (unlimited)" if amount <= 0 else f"{amount} GB"
if key == "referral_discount_percent":
return f"{max(int(value or 0), 0)}%"
if key == "referral_bonus_days":
return f"{max(int(value or 0), 0)} days"
if key == "notification_check_interval_hours":
return f"{max(int(value or 6), 1)} ч"
if key in {"payment_transfer_text", "payment_support_text"}:
return cls._compact_text(str(value), max_length=120)
return cls._compact_text(str(value))
@staticmethod
def _compact_text(raw_value: str, *, max_length: int = 80) -> str:
normalized = " ".join(raw_value.split()).strip()
if not normalized:
return "-"
if len(normalized) <= max_length:
return normalized
return f"{normalized[: max_length - 3]}..."
async def _get_overrides(self) -> dict[str, str]:
async with self._session_factory() as session:
rows = await session.execute(select(BotSetting))
return {
row.key: row.value
for row in rows.scalars().all()
}
def _normalize_value(self, key: str, raw_value: str) -> str:
value = raw_value.strip()
if key in {
"bot_support_url",
"bot_terms_url",
"bot_support_ticket_link",
"payment_review_link",
"bot_start_image_path",
"payment_external_squad_uuid",
"payment_user_tag",
"bot_public_username",
} and value == "-":
return ""
if key == "bot_brand_name":
if not value:
raise ValueError("Название бренда не может быть пустым.")
return value[:80]
if key == "bot_public_username":
return value.removeprefix("@")
if key == "bot_start_image_enabled":
return "true" if self._parse_bool_token(value) else "false"
if key == "bot_start_image_path":
return value
if key == "payment_plans_raw":
plans = self._parse_payment_plans(value)
return ",".join(f"{plan.days}:{plan.amount_rub}" for plan in plans)
if key == "payment_plan_traffic_limit_gb":
amount = self._parse_non_negative_int(value, "Лимит трафика")
return str(amount)
if key == "payment_plan_traffic_reset_period":
if not value:
raise ValueError("Стратегия сброса трафика не может быть пустой.")
return value.upper()
if key == "referral_enabled":
return "true" if self._parse_bool_token(value) else "false"
if key == "referral_discount_percent":
amount = self._parse_non_negative_int(value, "Размер скидки")
if amount > 100:
raise ValueError("Размер скидки должен быть в диапазоне от 0 до 100.")
return str(amount)
if key == "referral_bonus_days":
amount = self._parse_non_negative_int(value, "Размер бонуса")
return str(amount)
if key in {
"bot_support_url",
"bot_terms_url",
"bot_support_ticket_link",
"payment_review_link",
}:
return value
if key in {"payment_transfer_text", "payment_support_text"}:
if not value:
raise ValueError("Текст не может быть пустым.")
return value[:4000]
if key == "payment_internal_squad_uuids_raw":
normalized_parts = [part.strip() for part in value.split(",") if part.strip()]
if not normalized_parts:
raise ValueError("Нужно указать хотя бы один UUID внутренней группы.")
return ",".join(normalized_parts)
if key == "payment_external_squad_uuid":
return value
if key == "payment_username_prefix":
if not value:
raise ValueError("Префикс логина не может быть пустым.")
return value[:32]
if key == "payment_user_tag":
return value
if key == "notification_enabled":
return "true" if self._parse_bool_token(value) else "false"
if key == "notification_days_before":
parts = [p.strip() for p in value.split(",") if p.strip().isdigit() and int(p.strip()) > 0]
if not parts:
raise ValueError("Нужно указать хотя бы один день, например `1,3`.")
return ",".join(parts)
if key == "notification_check_interval_hours":
amount = self._parse_non_negative_int(value, "Интервал проверки")
if amount < 1:
raise ValueError("Интервал должен быть не менее 1 часа.")
return str(amount)
raise ValueError("Настройка пока не поддерживается.")
@staticmethod
def _parse_int(raw_value: object | None, *, default: int) -> int:
try:
return int(str(raw_value).strip())
except (TypeError, ValueError):
return default
@staticmethod
def _parse_bool(raw_value: object | None, *, default: bool) -> bool:
if raw_value is None:
return default
try:
return BotConfigService._parse_bool_token(str(raw_value))
except ValueError:
return default
@staticmethod
def _parse_bool_token(raw_value: str) -> bool:
normalized = raw_value.strip().lower()
if normalized in {"1", "true", "on", "yes", "y", "да", "вкл"}:
return True
if normalized in {"0", "false", "off", "no", "n", "нет", "выкл"}:
return False
raise ValueError("Введите `on` или `off`.")
@staticmethod
def _parse_non_negative_int(raw_value: str, label: str) -> int:
if not raw_value.strip().isdigit():
raise ValueError(f"{label} должен быть целым неотрицательным числом.")
return int(raw_value.strip())
@staticmethod
def _parse_payment_plans(raw_value: str) -> list[PaymentPlanSettings]:
values: list[PaymentPlanSettings] = []
for raw_part in raw_value.split(","):
part = raw_part.strip()
if not part:
continue
fragments = [fragment.strip() for fragment in part.split(":")]
if len(fragments) != 2 or not fragments[0].isdigit() or not fragments[1].isdigit():
raise ValueError(
"Тарифы должны быть в формате `дни:цена`, например `30:250,180:600`."
)
days = int(fragments[0])
amount_rub = int(fragments[1])
if days <= 0 or amount_rub <= 0:
raise ValueError("У тарифов дни и цена должны быть больше нуля.")
values.append(
PaymentPlanSettings(
code=f"{days}d",
days=days,
amount_rub=amount_rub,
)
)
if not values:
raise ValueError("Нужно указать хотя бы один тариф.")
return sorted(values, key=lambda item: item.days)

View File

@@ -0,0 +1,239 @@
from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from pathlib import Path
from aiogram import Bot
from aiogram.types import FSInputFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.db.base import utcnow
from app.db.models import SubscriptionNotification
from app.services.bot_config_service import BotConfigSnapshot, StaticBotConfigService
from app.services.sync_service import SyncService
from app.utils.formatters import format_datetime_with_days_left
logger = logging.getLogger(__name__)
NOTIFICATION_TYPE_EXPIRE_SOON = "EXPIRE_SOON"
class SubscriptionNotificationService:
def __init__(
self,
*,
session_factory: async_sessionmaker[AsyncSession],
sync_service: SyncService,
config_service: StaticBotConfigService,
project_root: Path,
) -> None:
self._session_factory = session_factory
self._sync_service = sync_service
self._config_service = config_service
self._project_root = project_root
async def _get_config(self) -> BotConfigSnapshot:
return await self._config_service.get_snapshot()
def _resolve_image_path(self, config: BotConfigSnapshot) -> Path | None:
if not config.bot_start_image_enabled:
return None
raw_path = config.bot_start_image_path.strip()
default_path = self._project_root / "assets" / "main.png"
if not raw_path:
return default_path if default_path.is_file() else None
image_path = Path(raw_path).expanduser()
if not image_path.is_absolute():
image_path = self._project_root / image_path
return image_path if image_path.is_file() else None
async def _already_notified(
self,
session: AsyncSession,
*,
remnawave_user_id: int,
notification_type: str,
expire_at_snapshot: datetime,
) -> bool:
existing = await session.scalar(
select(SubscriptionNotification.id).where(
SubscriptionNotification.remnawave_user_id == remnawave_user_id,
SubscriptionNotification.notification_type == notification_type,
SubscriptionNotification.expire_at_snapshot == expire_at_snapshot,
)
)
return existing is not None
async def _record_notification(
self,
session: AsyncSession,
*,
remnawave_user_id: int,
telegram_id: int,
notification_type: str,
expire_at_snapshot: datetime,
) -> None:
session.add(
SubscriptionNotification(
remnawave_user_id=remnawave_user_id,
telegram_id=telegram_id,
notification_type=notification_type,
expire_at_snapshot=expire_at_snapshot,
sent_at=utcnow(),
)
)
async def check_and_notify(self, bot: Bot) -> int:
config = await self._get_config()
if not config.notification_enabled:
return 0
days_list = config.notification_days_list
if not days_list:
return 0
total_sent = 0
image_path = self._resolve_image_path(config)
for days_before in days_list:
expiring = await self._sync_service.get_expiring_users(
days_before=days_before,
tolerance_hours=max(config.notification_check_interval_hours, 1),
)
for rw_user_id, telegram_id, expire_at in expiring:
if telegram_id is None:
continue
try:
sent = await self._send_notification(
bot,
rw_user_id=rw_user_id,
telegram_id=telegram_id,
expire_at=expire_at,
days_before=days_before,
brand_name=config.bot_brand_name,
image_path=image_path,
)
if sent:
total_sent += 1
except Exception:
logger.exception(
"Failed to send expiration notification to telegram_id=%s",
telegram_id,
)
return total_sent
async def _send_notification(
self,
bot: Bot,
*,
rw_user_id: int,
telegram_id: int,
expire_at: datetime,
days_before: int,
brand_name: str,
image_path: Path | None,
) -> bool:
notification_type = f"{NOTIFICATION_TYPE_EXPIRE_SOON}_{days_before}d"
async with self._session_factory() as session:
if await self._already_notified(
session,
remnawave_user_id=rw_user_id,
notification_type=notification_type,
expire_at_snapshot=expire_at,
):
return False
text = self._build_notification_text(
brand_name=brand_name,
expire_at=expire_at,
days_before=days_before,
)
try:
if image_path is not None:
await bot.send_photo(
chat_id=telegram_id,
photo=FSInputFile(str(image_path)),
caption=text,
)
else:
await bot.send_message(
chat_id=telegram_id,
text=text,
)
except Exception:
logger.warning(
"Cannot send notification to telegram_id=%s (blocked or unavailable)",
telegram_id,
)
return False
await self._record_notification(
session,
remnawave_user_id=rw_user_id,
telegram_id=telegram_id,
notification_type=notification_type,
expire_at_snapshot=expire_at,
)
await session.commit()
return True
@staticmethod
def _build_notification_text(
*,
brand_name: str,
expire_at: datetime,
days_before: int,
) -> str:
import html as html_module
if days_before <= 1:
urgency = "⚠️ Ваша подписка истекает <b>завтра</b>!"
else:
urgency = f"⏰ Ваша подписка истекает через <b>{days_before} дн.</b>"
lines = [
f"<b>{html_module.escape(brand_name)}</b>",
"",
urgency,
"",
(
"<blockquote>"
f"Дата истечения: <b>{format_datetime_with_days_left(expire_at)}</b>\n\n"
"Чтобы продлить доступ, откройте бот и выберите тариф."
"</blockquote>"
),
"",
"<i>Нажмите /start чтобы открыть меню и продлить подписку.</i>",
]
return "\n".join(lines)
async def run_periodic(self, bot: Bot, *, default_interval_hours: int = 6) -> None:
logger.info("Subscription notification periodic task started")
while True:
try:
config = await self._get_config()
interval_hours = max(config.notification_check_interval_hours, 1)
except Exception:
interval_hours = max(default_interval_hours, 1)
try:
total = await self.check_and_notify(bot)
if total > 0:
logger.info("Sent %d subscription expiration notifications", total)
except Exception:
logger.exception("Error in subscription notification check")
await asyncio.sleep(interval_hours * 3600)

View File

@@ -0,0 +1,983 @@
from __future__ import annotations
import html
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from math import ceil
from uuid import uuid4
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import PaymentPlanSettings, Settings
from app.db.base import utcnow
from app.db.models import PaymentOrder, ReferralBonus, ReferralInvite, TelegramUser
from app.schemas.remnawave import RemnawaveUser
from app.services.bot_config_service import BotConfigSnapshot, StaticBotConfigService
from app.services.remnawave_client import RemnawaveApiClient, RemnawaveApiError
from app.services.sync_service import SyncService
PENDING_PAYMENT_STATUS = "PENDING"
REVIEW_PAYMENT_STATUS = "REVIEW"
PROCESSING_PAYMENT_STATUS = "PROCESSING"
REJECTED_PAYMENT_STATUS = "REJECTED"
FULFILLED_PAYMENT_STATUS = "FULFILLED"
REFERRAL_BONUS_PENDING_STATUS = "PENDING"
REFERRAL_BONUS_PROCESSING_STATUS = "PROCESSING"
REFERRAL_BONUS_APPLIED_STATUS = "APPLIED"
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class PaymentPlan:
code: str
title: str
description: str
duration_days: int
amount_rub: int
discount_percent: int
original_amount_rub: int
applied_referral_code: str = ""
traffic_limit_bytes: int = 0
traffic_limit_strategy: str = "NO_RESET"
internal_squad_uuids: list[str] | None = None
external_squad_uuid: str | None = None
@property
def is_ready(self) -> bool:
return bool(self.internal_squad_uuids) and self.duration_days > 0 and self.amount_rub > 0
@dataclass(slots=True)
class CreatedPaymentOrder:
order_uuid: str
provision_username: str
plan: PaymentPlan
transfer_text: str
extends_existing_access: bool = False
@dataclass(slots=True)
class StoredPaymentOrder:
order_uuid: str
telegram_id: int
plan_title: str
plan_duration_days: int
amount_rub: int
provision_username: str
status: str
@dataclass(slots=True)
class IssuedAccess:
telegram_id: int
username: str
subscription_url: str
expire_at: datetime
short_uuid: str
remnawave_user_uuid: str
traffic_limit_bytes: int
referral_bonus: ReferralBonusGrant | None = None
is_renewal: bool = False
@dataclass(slots=True)
class ReferralBonusGrant:
inviter_telegram_id: int
bonus_days: int
status: str
total_applied_days: int = 0
expire_at: datetime | None = None
applied_to_user_uuid: str | None = None
@dataclass(slots=True)
class AppliedReferralBonus:
telegram_id: int
total_bonus_days: int
expire_at: datetime
applied_to_user_uuid: str
@dataclass(slots=True)
class PaymentAdminStats:
pending_orders: int = 0
review_orders: int = 0
pending_referral_bonuses: int = 0
class PaymentService:
def __init__(
self,
*,
settings: Settings,
session_factory: async_sessionmaker[AsyncSession],
remnawave_client: RemnawaveApiClient,
sync_service: SyncService,
config_service: StaticBotConfigService | None = None,
) -> None:
self._settings = settings
self._session_factory = session_factory
self._remnawave_client = remnawave_client
self._sync_service = sync_service
self._config_service = config_service or StaticBotConfigService(settings)
async def _get_config(self) -> BotConfigSnapshot:
return await self._config_service.get_snapshot()
async def get_available_plans(self, *, telegram_id: int) -> list[PaymentPlan]:
config = await self._get_config()
applied_referral_code = await self._get_applied_referral_code(
telegram_id,
config=config,
)
return [
self._build_plan(
plan,
applied_referral_code=applied_referral_code,
config=config,
)
for plan in config.payment_plans
]
async def create_order(
self,
*,
telegram_id: int,
username: str | None,
first_name: str | None,
last_name: str | None,
language_code: str | None,
plan_code: str,
) -> CreatedPaymentOrder:
config = await self._get_config()
applied_referral_code = await self._get_applied_referral_code(
telegram_id,
config=config,
)
plan = self._get_plan_by_code(
plan_code,
applied_referral_code=applied_referral_code,
config=config,
)
if not plan.is_ready:
raise ValueError(
"Покупка не настроена. Проверьте PAYMENT_PLANS и PAYMENT_INTERNAL_SQUAD_UUIDS."
)
existing_accesses = await self._sync_service.get_cached_users_for_telegram(telegram_id)
order_uuid = str(uuid4())
invoice_payload = f"manual:{plan.code}:{order_uuid}"
provision_username = self._build_provision_username(
config=config,
order_uuid=order_uuid,
telegram_id=telegram_id,
username=username,
first_name=first_name,
)
async with self._session_factory() as session:
user = await self._upsert_telegram_user(
session,
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
language_code=language_code,
)
session.add(
PaymentOrder(
order_uuid=order_uuid,
telegram_user_id=user.id,
telegram_id=telegram_id,
plan_code=plan.code,
plan_title=plan.title,
plan_duration_days=plan.duration_days,
traffic_limit_bytes=plan.traffic_limit_bytes,
traffic_limit_strategy=plan.traffic_limit_strategy,
amount_stars=plan.amount_rub,
currency="RUB",
status=PENDING_PAYMENT_STATUS,
invoice_payload=invoice_payload,
provision_username=provision_username,
error_message=applied_referral_code or None,
)
)
await session.commit()
return CreatedPaymentOrder(
order_uuid=order_uuid,
provision_username=provision_username,
plan=plan,
transfer_text=config.payment_transfer_text.strip(),
extends_existing_access=bool(existing_accesses),
)
async def get_order_for_user(self, *, order_uuid: str, telegram_id: int) -> StoredPaymentOrder:
async with self._session_factory() as session:
order = await session.scalar(
select(PaymentOrder).where(
PaymentOrder.order_uuid == order_uuid,
PaymentOrder.telegram_id == telegram_id,
)
)
if order is None:
raise ValueError("Заказ не найден.")
return StoredPaymentOrder(
order_uuid=order.order_uuid,
telegram_id=order.telegram_id,
plan_title=order.plan_title,
plan_duration_days=order.plan_duration_days,
amount_rub=order.amount_stars,
provision_username=order.provision_username,
status=order.status,
)
async def mark_order_under_review(self, *, order_uuid: str, telegram_id: int) -> StoredPaymentOrder:
async with self._session_factory() as session:
order = await session.scalar(
select(PaymentOrder)
.where(
PaymentOrder.order_uuid == order_uuid,
PaymentOrder.telegram_id == telegram_id,
)
.with_for_update()
)
if order is None:
raise ValueError("Заказ не найден.")
if order.status == REVIEW_PAYMENT_STATUS:
raise ValueError("Чек уже отправлен на проверку.")
if order.status == PROCESSING_PAYMENT_STATUS:
raise ValueError("Заказ уже обрабатывается модератором.")
if order.status == FULFILLED_PAYMENT_STATUS:
raise ValueError("Этот заказ уже подтверждён.")
if order.status == REJECTED_PAYMENT_STATUS:
raise ValueError("Этот заказ уже отклонён. Создайте новый платёж.")
if order.status != PENDING_PAYMENT_STATUS:
raise ValueError("Некорректный статус заказа для отправки чека.")
order.status = REVIEW_PAYMENT_STATUS
order.updated_at = utcnow()
await session.commit()
return StoredPaymentOrder(
order_uuid=order.order_uuid,
telegram_id=order.telegram_id,
plan_title=order.plan_title,
plan_duration_days=order.plan_duration_days,
amount_rub=order.amount_stars,
provision_username=order.provision_username,
status=order.status,
)
async def revert_order_to_pending(self, *, order_uuid: str, telegram_id: int) -> None:
async with self._session_factory() as session:
order = await session.scalar(
select(PaymentOrder)
.where(
PaymentOrder.order_uuid == order_uuid,
PaymentOrder.telegram_id == telegram_id,
)
.with_for_update()
)
if order is None or order.status != REVIEW_PAYMENT_STATUS:
return
order.status = PENDING_PAYMENT_STATUS
order.updated_at = utcnow()
await session.commit()
async def approve_order(self, *, order_uuid: str) -> IssuedAccess:
order = await self._claim_order_for_approval(order_uuid)
config = await self._get_config()
is_renewal = False
try:
remote_user: RemnawaveUser | None = None
if order.remnawave_user_uuid:
try:
remote_user = await self._remnawave_client.get_user_by_uuid(
order.remnawave_user_uuid
)
except RemnawaveApiError as exc:
if exc.status_code != 404:
raise
remote_user = None
if remote_user is None:
try:
remote_user = await self._remnawave_client.get_user_by_username(
order.provision_username
)
except RemnawaveApiError as exc:
if exc.status_code != 404:
raise
if remote_user is None:
remote_users = await self._remnawave_client.get_users_by_telegram_id(
order.telegram_id
)
remote_user = self._select_referral_bonus_target(remote_users)
if remote_user is None:
remote_user = await self._create_remnawave_user(order, config=config)
else:
is_renewal = True
remote_user = await self._extend_remnawave_user(
order,
remote_user,
config=config,
)
except RemnawaveApiError as exc:
await self._restore_review_after_failed_approval(order.order_uuid, exc.message)
raise
await self._save_fulfilled_order(order.order_uuid, remote_user)
own_bonus = None
try:
own_bonus = await self.apply_pending_referral_bonuses(
telegram_id=order.telegram_id,
refresh_cache=False,
)
except RemnawaveApiError as exc:
logger.warning(
"Could not apply pending referral bonus for telegram_id=%s: %s",
order.telegram_id,
exc.message,
)
try:
await self._sync_service.refresh_cached_users_for_telegram(
telegram_id=order.telegram_id,
)
except RemnawaveApiError:
# Access is already issued at this point; cache can be refreshed later.
pass
expire_at = remote_user.expire_at
if own_bonus is not None and own_bonus.applied_to_user_uuid == str(remote_user.uuid):
expire_at = own_bonus.expire_at
referral_bonus = await self._grant_referral_bonus(order, config=config)
return IssuedAccess(
telegram_id=order.telegram_id,
username=remote_user.username,
subscription_url=remote_user.subscription_url,
expire_at=expire_at,
short_uuid=remote_user.short_uuid,
remnawave_user_uuid=str(remote_user.uuid),
traffic_limit_bytes=remote_user.traffic_limit_bytes,
referral_bonus=referral_bonus,
is_renewal=is_renewal,
)
async def reject_order(self, *, order_uuid: str, reason: str | None = None) -> StoredPaymentOrder:
async with self._session_factory() as session:
order = await session.scalar(
select(PaymentOrder)
.where(PaymentOrder.order_uuid == order_uuid)
.with_for_update()
)
if order is None:
raise ValueError("Заказ не найден.")
if order.status == FULFILLED_PAYMENT_STATUS:
raise ValueError("Подтверждённый заказ нельзя отклонить.")
if order.status == PROCESSING_PAYMENT_STATUS:
raise ValueError("Заказ уже обрабатывается другим модератором.")
if order.status == PENDING_PAYMENT_STATUS:
raise ValueError("Чек ещё не отправлен на проверку.")
if order.status == REJECTED_PAYMENT_STATUS:
raise ValueError("Заказ уже отклонён.")
order.status = REJECTED_PAYMENT_STATUS
order.error_message = reason or "Платёж отклонён"
order.updated_at = utcnow()
await session.commit()
return StoredPaymentOrder(
order_uuid=order.order_uuid,
telegram_id=order.telegram_id,
plan_title=order.plan_title,
plan_duration_days=order.plan_duration_days,
amount_rub=order.amount_stars,
provision_username=order.provision_username,
status=order.status,
)
async def get_referral_code_for_order(self, *, order_uuid: str) -> str:
order = await self._get_order(order_uuid)
return order.error_message or ""
def build_transfer_text(self, *, order: CreatedPaymentOrder) -> str:
lines = [
"<b>Оплата тарифа</b>",
"",
f"<b>{html.escape(order.plan.title)}</b>",
html.escape(order.plan.description),
"",
f"Сумма к переводу: <b>{order.plan.amount_rub} ₽</b>",
]
if order.plan.applied_referral_code:
lines.append(
f"Реферальный код: <code>{order.plan.applied_referral_code}</code> "
f"(<b>-{order.plan.discount_percent}%</b>)"
)
if order.extends_existing_access:
lines.extend(
[
"",
(
"<blockquote>"
"У вас уже есть доступ. После подтверждения оплаты бот автоматически "
"продлит текущую подписку на выбранный срок."
"</blockquote>"
),
]
)
lines.extend(
[
"",
"<b>Реквизиты</b>",
(
"<blockquote>"
f"{html.escape(order.transfer_text or 'Укажите реквизиты в PAYMENT_TRANSFER_TEXT.')}"
"</blockquote>"
),
"",
"После перевода нажмите кнопку ниже и отправьте чек следующим сообщением.",
]
)
return "\n".join(lines)
def _get_plan_by_code(
self,
plan_code: str,
*,
applied_referral_code: str,
config: BotConfigSnapshot,
) -> PaymentPlan:
for plan in config.payment_plans:
if plan.code == plan_code:
return self._build_plan(
plan,
applied_referral_code=applied_referral_code,
config=config,
)
raise ValueError("Тариф не найден.")
def _build_plan(
self,
plan_settings: PaymentPlanSettings,
*,
applied_referral_code: str,
config: BotConfigSnapshot,
) -> PaymentPlan:
discount_percent = max(config.referral_discount_percent, 0) if applied_referral_code else 0
discounted_amount = self._apply_discount(
amount_rub=plan_settings.amount_rub,
discount_percent=discount_percent,
)
traffic_limit_bytes = max(config.payment_plan_traffic_limit_gb, 0) * 1024**3
return PaymentPlan(
code=plan_settings.code,
title=f"{config.bot_brand_name} на {plan_settings.days} дней",
description=f"Доступ к VPN на {plan_settings.days} дней",
duration_days=plan_settings.days,
amount_rub=discounted_amount,
discount_percent=discount_percent,
original_amount_rub=plan_settings.amount_rub,
applied_referral_code=applied_referral_code,
traffic_limit_bytes=traffic_limit_bytes,
traffic_limit_strategy=config.payment_plan_traffic_reset_period.strip() or "NO_RESET",
internal_squad_uuids=config.payment_internal_squad_uuids,
external_squad_uuid=config.payment_external_squad_uuid_normalized,
)
@staticmethod
def _apply_discount(*, amount_rub: int, discount_percent: int) -> int:
if discount_percent <= 0:
return amount_rub
return max(1, ceil(amount_rub * (100 - discount_percent) / 100))
async def _get_applied_referral_code(
self,
telegram_id: int,
*,
config: BotConfigSnapshot | None = None,
) -> str:
resolved_config = config or await self._get_config()
if not resolved_config.referral_enabled:
return ""
summary = await self._sync_service.get_referral_summary(telegram_id)
return summary.applied_referral_code.strip().upper()
async def apply_pending_referral_bonuses(
self,
*,
telegram_id: int,
refresh_cache: bool = True,
) -> AppliedReferralBonus | None:
config = await self._get_config()
if not config.referral_enabled:
return None
claimed_bonuses = await self._claim_pending_referral_bonuses(telegram_id)
if not claimed_bonuses:
return None
total_bonus_days = sum(max(bonus.bonus_days, 0) for bonus in claimed_bonuses)
if total_bonus_days <= 0:
await self._restore_claimed_referral_bonuses(
[bonus.id for bonus in claimed_bonuses],
status=REFERRAL_BONUS_PENDING_STATUS,
)
return None
remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)
target_user = self._select_referral_bonus_target(remote_users)
if target_user is None:
await self._restore_claimed_referral_bonuses(
[bonus.id for bonus in claimed_bonuses],
status=REFERRAL_BONUS_PENDING_STATUS,
)
return None
current_expire_at = target_user.expire_at
if current_expire_at.tzinfo is None:
current_expire_at = current_expire_at.replace(tzinfo=timezone.utc)
next_expire_at = max(current_expire_at, datetime.now(timezone.utc)) + timedelta(
days=total_bonus_days
)
try:
updated_user = await self._remnawave_client.update_user(
{
"uuid": str(target_user.uuid),
"expireAt": next_expire_at.isoformat().replace("+00:00", "Z"),
"description": (
f"Referral bonus applied by Telegram bot "
f"({total_bonus_days} days)"
),
}
)
except RemnawaveApiError:
await self._restore_claimed_referral_bonuses(
[bonus.id for bonus in claimed_bonuses],
status=REFERRAL_BONUS_PENDING_STATUS,
)
raise
await self._mark_referral_bonuses_applied(
[bonus.id for bonus in claimed_bonuses],
applied_to_user_uuid=str(updated_user.uuid),
)
if refresh_cache:
try:
await self._sync_service.refresh_cached_users_for_telegram(
telegram_id=telegram_id,
)
except RemnawaveApiError:
pass
return AppliedReferralBonus(
telegram_id=telegram_id,
total_bonus_days=total_bonus_days,
expire_at=updated_user.expire_at,
applied_to_user_uuid=str(updated_user.uuid),
)
async def get_admin_stats(self) -> PaymentAdminStats:
async with self._session_factory() as session:
pending_orders = await session.scalar(
select(func.count(PaymentOrder.id)).where(
PaymentOrder.status == PENDING_PAYMENT_STATUS
)
)
review_orders = await session.scalar(
select(func.count(PaymentOrder.id)).where(
PaymentOrder.status == REVIEW_PAYMENT_STATUS
)
)
pending_referral_bonuses = await session.scalar(
select(func.count(ReferralBonus.id)).where(
ReferralBonus.status == REFERRAL_BONUS_PENDING_STATUS
)
)
return PaymentAdminStats(
pending_orders=int(pending_orders or 0),
review_orders=int(review_orders or 0),
pending_referral_bonuses=int(pending_referral_bonuses or 0),
)
async def _get_order(self, order_uuid: str) -> PaymentOrder:
async with self._session_factory() as session:
order = await session.scalar(
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
)
if order is None:
raise ValueError("Заказ не найден.")
return order
async def _claim_order_for_approval(self, order_uuid: str) -> PaymentOrder:
async with self._session_factory() as session:
order = await session.scalar(
select(PaymentOrder)
.where(PaymentOrder.order_uuid == order_uuid)
.with_for_update()
)
if order is None:
raise ValueError("Заказ не найден.")
if order.status == PENDING_PAYMENT_STATUS:
raise ValueError("Чек ещё не отправлен на проверку.")
if order.status == PROCESSING_PAYMENT_STATUS:
raise ValueError("Заказ уже обрабатывается другим модератором.")
if order.status == REJECTED_PAYMENT_STATUS:
raise ValueError("Заказ уже отклонён.")
if order.status == FULFILLED_PAYMENT_STATUS:
raise ValueError("Этот заказ уже подтверждён.")
if order.status != REVIEW_PAYMENT_STATUS:
raise ValueError("Некорректный статус заказа для подтверждения.")
order.status = PROCESSING_PAYMENT_STATUS
order.updated_at = utcnow()
await session.commit()
return order
async def _create_remnawave_user(
self,
order: PaymentOrder,
*,
config: BotConfigSnapshot,
) -> RemnawaveUser:
expire_at = datetime.now(timezone.utc) + timedelta(days=order.plan_duration_days)
body: dict[str, object] = {
"username": order.provision_username,
"expireAt": expire_at.isoformat().replace("+00:00", "Z"),
"trafficLimitBytes": order.traffic_limit_bytes,
"trafficLimitStrategy": order.traffic_limit_strategy,
"telegramId": order.telegram_id,
"description": f"Created by Telegram bot manual payment {order.order_uuid}",
"activeInternalSquads": config.payment_internal_squad_uuids,
}
if config.payment_user_tag_normalized:
body["tag"] = config.payment_user_tag_normalized
if config.payment_external_squad_uuid_normalized:
body["externalSquadUuid"] = config.payment_external_squad_uuid_normalized
return await self._remnawave_client.create_user(body)
async def _extend_remnawave_user(
self,
order: PaymentOrder,
remote_user: RemnawaveUser,
*,
config: BotConfigSnapshot,
) -> RemnawaveUser:
current_expire_at = remote_user.expire_at
if current_expire_at.tzinfo is None:
current_expire_at = current_expire_at.replace(tzinfo=timezone.utc)
base_expire_at = max(current_expire_at, datetime.now(timezone.utc))
next_expire_at = base_expire_at + timedelta(days=order.plan_duration_days)
traffic_limit_bytes = order.traffic_limit_bytes
if traffic_limit_bytes <= 0:
traffic_limit_bytes = remote_user.traffic_limit_bytes
body: dict[str, object] = {
"uuid": str(remote_user.uuid),
"username": remote_user.username,
"expireAt": next_expire_at.isoformat().replace("+00:00", "Z"),
"trafficLimitBytes": traffic_limit_bytes,
"trafficLimitStrategy": order.traffic_limit_strategy,
"telegramId": order.telegram_id,
"description": f"Updated by Telegram bot manual payment {order.order_uuid}",
"activeInternalSquads": config.payment_internal_squad_uuids,
}
if config.payment_user_tag_normalized:
body["tag"] = config.payment_user_tag_normalized
if config.payment_external_squad_uuid_normalized:
body["externalSquadUuid"] = config.payment_external_squad_uuid_normalized
return await self._remnawave_client.update_user(body)
async def _save_fulfilled_order(self, order_uuid: str, remote_user: RemnawaveUser) -> None:
async with self._session_factory() as session:
order = await session.scalar(
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
)
if order is None:
return
order.status = FULFILLED_PAYMENT_STATUS
order.remnawave_user_uuid = str(remote_user.uuid)
order.subscription_url = remote_user.subscription_url
order.error_message = None
order.paid_at = utcnow()
order.fulfilled_at = utcnow()
order.updated_at = utcnow()
await session.commit()
async def _save_order_error(self, order_uuid: str, error_message: str) -> None:
async with self._session_factory() as session:
order = await session.scalar(
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
)
if order is None:
return
order.error_message = error_message
order.updated_at = utcnow()
await session.commit()
async def _restore_review_after_failed_approval(
self,
order_uuid: str,
error_message: str,
) -> None:
async with self._session_factory() as session:
order = await session.scalar(
select(PaymentOrder)
.where(PaymentOrder.order_uuid == order_uuid)
.with_for_update()
)
if order is None:
return
if order.status == PROCESSING_PAYMENT_STATUS:
order.status = REVIEW_PAYMENT_STATUS
order.error_message = error_message
order.updated_at = utcnow()
await session.commit()
async def _grant_referral_bonus(
self,
order: PaymentOrder,
*,
config: BotConfigSnapshot,
) -> ReferralBonusGrant | None:
if not config.referral_enabled:
return None
bonus_days = max(config.referral_bonus_days, 0)
if bonus_days <= 0:
return None
inviter_telegram_id = await self._get_inviter_telegram_id(order.telegram_id)
if inviter_telegram_id is None or inviter_telegram_id == order.telegram_id:
return None
await self._create_referral_bonus(
inviter_telegram_id=inviter_telegram_id,
invited_telegram_id=order.telegram_id,
source_order_uuid=order.order_uuid,
bonus_days=bonus_days,
)
try:
applied_bonus = await self.apply_pending_referral_bonuses(
telegram_id=inviter_telegram_id,
)
except RemnawaveApiError as exc:
logger.warning(
"Could not apply referral bonus for inviter=%s after order=%s: %s",
inviter_telegram_id,
order.order_uuid,
exc.message,
)
applied_bonus = None
if applied_bonus is None:
return ReferralBonusGrant(
inviter_telegram_id=inviter_telegram_id,
bonus_days=bonus_days,
status=REFERRAL_BONUS_PENDING_STATUS,
)
return ReferralBonusGrant(
inviter_telegram_id=inviter_telegram_id,
bonus_days=bonus_days,
status=REFERRAL_BONUS_APPLIED_STATUS,
total_applied_days=applied_bonus.total_bonus_days,
expire_at=applied_bonus.expire_at,
applied_to_user_uuid=applied_bonus.applied_to_user_uuid,
)
async def _get_inviter_telegram_id(self, invited_telegram_id: int) -> int | None:
async with self._session_factory() as session:
return await session.scalar(
select(ReferralInvite.inviter_telegram_id).where(
ReferralInvite.invited_telegram_id == invited_telegram_id
)
)
async def _create_referral_bonus(
self,
*,
inviter_telegram_id: int,
invited_telegram_id: int,
source_order_uuid: str,
bonus_days: int,
) -> None:
async with self._session_factory() as session:
existing = await session.scalar(
select(ReferralBonus).where(
ReferralBonus.source_order_uuid == source_order_uuid
)
)
if existing is not None:
return
session.add(
ReferralBonus(
inviter_telegram_id=inviter_telegram_id,
invited_telegram_id=invited_telegram_id,
source_order_uuid=source_order_uuid,
bonus_days=bonus_days,
status=REFERRAL_BONUS_PENDING_STATUS,
)
)
await session.commit()
async def _claim_pending_referral_bonuses(self, telegram_id: int) -> list[ReferralBonus]:
async with self._session_factory() as session:
bonuses = list(
(
await session.scalars(
select(ReferralBonus)
.where(
ReferralBonus.inviter_telegram_id == telegram_id,
ReferralBonus.status == REFERRAL_BONUS_PENDING_STATUS,
)
.order_by(ReferralBonus.created_at.asc(), ReferralBonus.id.asc())
.with_for_update()
)
).all()
)
if not bonuses:
return []
for bonus in bonuses:
bonus.status = REFERRAL_BONUS_PROCESSING_STATUS
bonus.updated_at = utcnow()
await session.commit()
return bonuses
async def _restore_claimed_referral_bonuses(
self,
bonus_ids: list[int],
*,
status: str,
) -> None:
if not bonus_ids:
return
async with self._session_factory() as session:
bonuses = list(
(
await session.scalars(
select(ReferralBonus)
.where(ReferralBonus.id.in_(bonus_ids))
.with_for_update()
)
).all()
)
for bonus in bonuses:
bonus.status = status
bonus.updated_at = utcnow()
await session.commit()
async def _mark_referral_bonuses_applied(
self,
bonus_ids: list[int],
*,
applied_to_user_uuid: str,
) -> None:
if not bonus_ids:
return
async with self._session_factory() as session:
bonuses = list(
(
await session.scalars(
select(ReferralBonus)
.where(ReferralBonus.id.in_(bonus_ids))
.with_for_update()
)
).all()
)
for bonus in bonuses:
bonus.status = REFERRAL_BONUS_APPLIED_STATUS
bonus.applied_to_user_uuid = applied_to_user_uuid
bonus.applied_at = utcnow()
bonus.updated_at = utcnow()
await session.commit()
@staticmethod
def _select_referral_bonus_target(
remote_users: list[RemnawaveUser],
) -> RemnawaveUser | None:
if not remote_users:
return None
def _sort_key(user: RemnawaveUser) -> tuple[int, datetime, str]:
expire_at = user.expire_at
if expire_at.tzinfo is None:
expire_at = expire_at.replace(tzinfo=timezone.utc)
return (0 if user.status.upper() == "ACTIVE" else 1, expire_at, user.username)
return sorted(remote_users, key=_sort_key)[0]
async def _upsert_telegram_user(
self,
session: AsyncSession,
*,
telegram_id: int,
username: str | None,
first_name: str | None,
last_name: str | None,
language_code: str | None,
) -> TelegramUser:
record = await session.scalar(
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
)
if record is None:
record = TelegramUser(telegram_id=telegram_id)
session.add(record)
record.username = username
record.first_name = first_name
record.last_name = last_name
record.language_code = language_code
record.is_admin = self._settings.is_admin(telegram_id)
record.last_seen_at = utcnow()
record.updated_at = utcnow()
await session.flush()
return record
def _build_provision_username(
self,
*,
config: BotConfigSnapshot,
order_uuid: str,
telegram_id: int,
username: str | None,
first_name: str | None,
) -> str:
base_name = username or first_name or "user"
normalized_name = "".join(
ch for ch in base_name if ch.isalnum() or ch in "_-"
).strip("-_")
if not normalized_name:
normalized_name = "user"
prefix = config.payment_username_prefix_normalized or "Oreol"
unique_suffix = order_uuid.replace("-", "")[:6]
provision_username = f"{prefix}-{telegram_id}-{unique_suffix}-{normalized_name}"
return provision_username[:36]

View File

@@ -0,0 +1,224 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
import httpx
from app.schemas.remnawave import PaginatedUsers, RemnawaveUser, ResolvedUser, SubscriptionRequestHistory
UUID_RE = re.compile(
r"^[0-9a-fA-F]{8}-"
r"[0-9a-fA-F]{4}-"
r"[0-9a-fA-F]{4}-"
r"[0-9a-fA-F]{4}-"
r"[0-9a-fA-F]{12}$"
)
class RemnawaveApiError(RuntimeError):
def __init__(self, *, status_code: int, message: str, payload: Any | None = None) -> None:
self.status_code = status_code
self.message = message
self.payload = payload
super().__init__(message)
class RemnawaveApiClient:
def __init__(
self,
*,
base_url: str,
api_token: str,
caddy_api_key: str = "",
timeout_seconds: float = 20.0,
client: httpx.AsyncClient | None = None,
) -> None:
self._managed_client = client is None
self._client = client or httpx.AsyncClient(
base_url=self.normalize_base_url(base_url),
timeout=timeout_seconds,
headers=self._build_headers(
base_url=base_url,
api_token=api_token,
caddy_api_key=caddy_api_key,
),
)
@staticmethod
def normalize_base_url(base_url: str) -> str:
normalized = base_url.strip().rstrip("/")
if not normalized.endswith("/api"):
normalized = f"{normalized}/api"
return f"{normalized}/"
@staticmethod
def unwrap_payload(payload: Any) -> Any:
if isinstance(payload, Mapping) and "response" in payload:
return payload["response"]
return payload
@staticmethod
def _build_headers(
*,
base_url: str,
api_token: str,
caddy_api_key: str,
) -> dict[str, str]:
headers: dict[str, str] = {"Accept": "application/json"}
if api_token:
headers["Authorization"] = (
api_token if api_token.startswith("Bearer ") else f"Bearer {api_token}"
)
if caddy_api_key:
headers["X-Api-Key"] = caddy_api_key
if base_url.startswith("http://"):
headers["x-forwarded-proto"] = "https"
headers["x-forwarded-for"] = "127.0.0.1"
return headers
async def close(self) -> None:
if self._managed_client:
await self._client.aclose()
async def get_users_by_telegram_id(self, telegram_id: int) -> list[RemnawaveUser]:
payload = await self._request_json("GET", f"users/by-telegram-id/{telegram_id}")
return [RemnawaveUser.model_validate(item) for item in payload]
async def get_user_by_uuid(self, user_uuid: str) -> RemnawaveUser:
payload = await self._request_json("GET", f"users/{user_uuid}")
return RemnawaveUser.model_validate(payload)
async def get_user_by_short_uuid(self, short_uuid: str) -> RemnawaveUser:
payload = await self._request_json("GET", f"users/by-short-uuid/{short_uuid}")
return RemnawaveUser.model_validate(payload)
async def get_user_by_username(self, username: str) -> RemnawaveUser:
payload = await self._request_json("GET", f"users/by-username/{username}")
return RemnawaveUser.model_validate(payload)
async def create_user(self, body: dict[str, Any]) -> RemnawaveUser:
payload = await self._request_json("POST", "users", json=body)
return RemnawaveUser.model_validate(payload)
async def update_user(self, body: dict[str, Any]) -> RemnawaveUser:
payload = await self._request_json("PATCH", "users", json=body)
return RemnawaveUser.model_validate(payload)
async def get_all_users(self, *, start: int = 0, size: int = 100) -> PaginatedUsers:
payload = await self._request_json(
"GET",
"users",
params={"start": start, "size": size},
)
return PaginatedUsers.model_validate(payload)
async def get_user_subscription_history(self, user_uuid: str) -> SubscriptionRequestHistory:
payload = await self._request_json(
"GET",
f"users/{user_uuid}/subscription-request-history",
)
return SubscriptionRequestHistory.model_validate(payload)
async def resolve_user(self, identifier: str) -> ResolvedUser:
body: dict[str, Any]
if UUID_RE.match(identifier):
body = {"uuid": identifier}
elif identifier.isdigit():
body = {"id": int(identifier)}
else:
try:
payload = await self._request_json(
"POST",
"users/resolve",
json={"shortUuid": identifier},
)
return ResolvedUser.model_validate(payload)
except RemnawaveApiError as exc:
if exc.status_code != 404:
raise
body = {"username": identifier}
payload = await self._request_json("POST", "users/resolve", json=body)
return ResolvedUser.model_validate(payload)
async def _request_json(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
) -> Any:
response = await self._client.request(
method=method,
url=path.lstrip("/"),
params=params,
json=json,
)
if response.is_error:
raise self._build_error(response)
try:
payload = response.json()
except ValueError:
return response.text
return self.unwrap_payload(payload)
def _build_error(self, response: httpx.Response) -> RemnawaveApiError:
try:
payload = response.json()
except ValueError:
payload = None
message = "Unknown Remnawave API error"
if isinstance(payload, Mapping):
for key in ("message", "error", "code"):
candidate = payload.get(key)
if candidate:
message = str(candidate)
break
error_code = payload.get("errorCode")
if error_code:
message = f"{message} [{error_code}]"
error_items = payload.get("errors")
if isinstance(error_items, list):
details: list[str] = []
for item in error_items:
if not isinstance(item, Mapping):
continue
path_value = item.get("path")
if isinstance(path_value, list):
path_text = ".".join(str(part) for part in path_value if part is not None)
elif path_value is not None:
path_text = str(path_value)
else:
path_text = ""
detail_message = str(item.get("message") or item.get("code") or "").strip()
if not detail_message:
continue
if path_text:
details.append(f"{path_text}: {detail_message}")
else:
details.append(detail_message)
if details:
message = f"{message} | {'; '.join(details)}"
elif response.text:
message = response.text
return RemnawaveApiError(
status_code=response.status_code,
message=message,
payload=payload,
)

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.db.base import utcnow
from app.db.models import SupportTicket
class SupportTicketService:
def __init__(self, *, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._session_factory = session_factory
async def create_ticket(
self,
*,
public_id: str,
telegram_id: int,
username: str | None,
display_name: str,
user_message: str,
support_chat_id: int,
support_thread_id: int | None,
support_message_id: int,
) -> SupportTicket:
async with self._session_factory() as session:
ticket = SupportTicket(
public_id=public_id,
telegram_id=telegram_id,
username=username,
display_name=display_name,
user_message=user_message,
support_chat_id=support_chat_id,
support_thread_id=support_thread_id,
support_message_id=support_message_id,
status="OPEN",
)
session.add(ticket)
await session.commit()
await session.refresh(ticket)
return ticket
async def get_ticket_by_support_message(
self,
*,
support_chat_id: int,
support_message_id: int,
) -> SupportTicket | None:
async with self._session_factory() as session:
return await session.scalar(
select(SupportTicket).where(
SupportTicket.support_chat_id == support_chat_id,
SupportTicket.support_message_id == support_message_id,
)
)
async def mark_answered(self, *, ticket_id: int) -> None:
async with self._session_factory() as session:
ticket = await session.get(SupportTicket, ticket_id)
if ticket is None:
return
ticket.status = "ANSWERED"
ticket.updated_at = utcnow()
await session.commit()
async def get_open_tickets_count(self) -> int:
async with self._session_factory() as session:
total = await session.scalar(
select(func.count(SupportTicket.id)).where(SupportTicket.status == "OPEN")
)
return int(total or 0)

View File

@@ -0,0 +1,811 @@
from __future__ import annotations
import secrets
import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from sqlalchemy import delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import Settings
from app.db.base import utcnow
from app.db.models import (
ReferralCode,
ReferralInvite,
InternalSquad,
RemnawaveUser,
RemnawaveUserInternalSquad,
SubscriptionRequestLog,
TelegramUser,
)
from app.schemas.remnawave import RemnawaveUser as RemoteRemnawaveUser
from app.schemas.remnawave import SubscriptionRequestHistory
from app.services.remnawave_client import RemnawaveApiClient
@dataclass(slots=True)
class CachedUserView:
record: RemnawaveUser
internal_squads: list[str] = field(default_factory=list)
recent_requests: list[SubscriptionRequestLog] = field(default_factory=list)
@dataclass(slots=True)
class ReferralSummary:
total_invited: int = 0
referral_code: str = ""
applied_referral_code: str = ""
recent_names: list[str] = field(default_factory=list)
@dataclass(slots=True)
class AdminStats:
total_telegram_users: int = 0
total_cached_users: int = 0
active_cached_users: int = 0
@dataclass(slots=True)
class AdminTelegramUserListItem:
telegram_id: int
username: str | None
first_name: str | None
last_name: str | None
language_code: str | None
is_admin: bool
is_blocked: bool
last_seen_at: datetime
created_at: datetime
@property
def display_name(self) -> str:
value = " ".join(part for part in [self.first_name, self.last_name] if part).strip()
return value or self.username or str(self.telegram_id)
@dataclass(slots=True)
class AdminTelegramUsersPage:
items: list[AdminTelegramUserListItem] = field(default_factory=list)
page: int = 1
page_size: int = 8
total_items: int = 0
total_pages: int = 1
@dataclass(slots=True)
class AdminTelegramUserProfile:
user: TelegramUser
referral_code: str = ""
applied_referral_code: str = ""
invited_count: int = 0
recent_invited_names: list[str] = field(default_factory=list)
accesses: list[CachedUserView] = field(default_factory=list)
class SyncService:
def __init__(
self,
*,
settings: Settings,
session_factory: async_sessionmaker[AsyncSession],
remnawave_client: RemnawaveApiClient,
) -> None:
self._settings = settings
self._session_factory = session_factory
self._remnawave_client = remnawave_client
async def is_user_blocked(self, telegram_id: int) -> bool:
async with self._session_factory() as session:
is_blocked = await session.scalar(
select(TelegramUser.is_blocked).where(
TelegramUser.telegram_id == telegram_id
)
)
return bool(is_blocked)
async def block_telegram_user(self, telegram_id: int) -> None:
async with self._session_factory() as session:
user = await session.scalar(
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
)
if user is not None:
user.is_blocked = True
user.updated_at = utcnow()
await session.commit()
async def unblock_telegram_user(self, telegram_id: int) -> None:
async with self._session_factory() as session:
user = await session.scalar(
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
)
if user is not None:
user.is_blocked = False
user.updated_at = utcnow()
await session.commit()
async def get_expiring_users(
self,
*,
days_before: int,
tolerance_hours: int = 6,
) -> list[tuple[int, int, datetime]]:
"""Return (remnawave_user.id, telegram_id, expire_at) for users expiring within window."""
from datetime import timedelta
now = utcnow()
window_start = now + timedelta(days=days_before) - timedelta(hours=tolerance_hours)
window_end = now + timedelta(days=days_before) + timedelta(hours=tolerance_hours)
async with self._session_factory() as session:
rows = await session.execute(
select(
RemnawaveUser.id,
RemnawaveUser.telegram_id,
RemnawaveUser.expire_at,
)
.where(
RemnawaveUser.status == "ACTIVE",
RemnawaveUser.telegram_id.isnot(None),
RemnawaveUser.expire_at >= window_start,
RemnawaveUser.expire_at <= window_end,
)
)
return [(row[0], row[1], row[2]) for row in rows.all()]
async def register_telegram_user(
self,
*,
telegram_id: int,
username: str | None,
first_name: str | None,
last_name: str | None,
language_code: str | None,
) -> None:
async with self._session_factory() as session:
await self._upsert_telegram_user(
session,
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
language_code=language_code,
)
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
await session.commit()
async def apply_referral_code(
self,
*,
telegram_id: int,
username: str | None,
first_name: str | None,
last_name: str | None,
language_code: str | None,
referral_code: str,
) -> str:
async with self._session_factory() as session:
invited = await self._upsert_telegram_user(
session,
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
language_code=language_code,
)
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
existing = await session.scalar(
select(ReferralInvite).where(
ReferralInvite.invited_telegram_id == telegram_id
)
)
if existing is not None:
raise ValueError("Реферальный код уже применен к вашему аккаунту.")
inviter_telegram_id = await self._find_inviter_by_referral_code(session, referral_code)
if inviter_telegram_id is None:
raise ValueError("Реферальный код не найден.")
if inviter_telegram_id == telegram_id:
raise ValueError("Нельзя использовать собственный реферальный код.")
invite_created = await self._upsert_referral_invite(
session,
invited=invited,
referred_by_telegram_id=inviter_telegram_id,
)
if not invite_created:
raise ValueError("Не удалось применить реферальный код.")
await session.commit()
return referral_code.strip().upper()
async def get_referral_summary(self, telegram_id: int) -> ReferralSummary:
async with self._session_factory() as session:
referral_code = await self._get_or_create_referral_code(
session,
telegram_id=telegram_id,
)
await session.commit()
total_invited = await session.scalar(
select(func.count(ReferralInvite.id)).where(
ReferralInvite.inviter_telegram_id == telegram_id
)
)
recent_rows = await session.execute(
select(ReferralInvite.invited_display_name)
.where(ReferralInvite.inviter_telegram_id == telegram_id)
.order_by(ReferralInvite.created_at.desc())
.limit(5)
)
recent_names = list(recent_rows.scalars().all())
applied_referral_code = await session.scalar(
select(ReferralCode.code)
.join(
ReferralInvite,
ReferralInvite.inviter_telegram_id == ReferralCode.telegram_id,
)
.where(ReferralInvite.invited_telegram_id == telegram_id)
)
return ReferralSummary(
total_invited=int(total_invited or 0),
referral_code=referral_code,
applied_referral_code=applied_referral_code or "",
recent_names=recent_names,
)
async def get_admin_stats(self) -> AdminStats:
async with self._session_factory() as session:
total_telegram_users = await session.scalar(
select(func.count(TelegramUser.id))
)
total_cached_users = await session.scalar(
select(func.count(RemnawaveUser.id))
)
active_cached_users = await session.scalar(
select(func.count(RemnawaveUser.id)).where(RemnawaveUser.status == "ACTIVE")
)
return AdminStats(
total_telegram_users=int(total_telegram_users or 0),
total_cached_users=int(total_cached_users or 0),
active_cached_users=int(active_cached_users or 0),
)
async def get_admin_telegram_users_page(
self,
*,
page: int,
page_size: int = 8,
) -> AdminTelegramUsersPage:
resolved_page_size = max(1, min(page_size, 20))
async with self._session_factory() as session:
total_items = int(
await session.scalar(select(func.count(TelegramUser.id))) or 0
)
total_pages = max(1, math.ceil(total_items / resolved_page_size)) if total_items else 1
resolved_page = min(max(page, 1), total_pages)
rows = list(
(
await session.scalars(
select(TelegramUser)
.order_by(
TelegramUser.last_seen_at.desc(),
TelegramUser.created_at.desc(),
TelegramUser.id.desc(),
)
.offset((resolved_page - 1) * resolved_page_size)
.limit(resolved_page_size)
)
).all()
)
return AdminTelegramUsersPage(
items=[
AdminTelegramUserListItem(
telegram_id=row.telegram_id,
username=row.username,
first_name=row.first_name,
last_name=row.last_name,
language_code=row.language_code,
is_admin=row.is_admin,
is_blocked=row.is_blocked,
last_seen_at=row.last_seen_at,
created_at=row.created_at,
)
for row in rows
],
page=resolved_page,
page_size=resolved_page_size,
total_items=total_items,
total_pages=total_pages,
)
async def get_admin_telegram_user_profile(
self,
*,
telegram_id: int,
) -> AdminTelegramUserProfile | None:
async with self._session_factory() as session:
user = await session.scalar(
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
)
if user is None:
return None
referral_code = await session.scalar(
select(ReferralCode.code).where(ReferralCode.telegram_id == telegram_id)
)
invited_count = await session.scalar(
select(func.count(ReferralInvite.id)).where(
ReferralInvite.inviter_telegram_id == telegram_id
)
)
recent_rows = await session.execute(
select(ReferralInvite.invited_display_name)
.where(ReferralInvite.inviter_telegram_id == telegram_id)
.order_by(ReferralInvite.created_at.desc())
.limit(5)
)
recent_invited_names = list(recent_rows.scalars().all())
applied_referral_code = await session.scalar(
select(ReferralCode.code)
.join(
ReferralInvite,
ReferralInvite.inviter_telegram_id == ReferralCode.telegram_id,
)
.where(ReferralInvite.invited_telegram_id == telegram_id)
)
accesses = await self.get_cached_users_for_telegram(telegram_id)
return AdminTelegramUserProfile(
user=user,
referral_code=referral_code or "",
applied_referral_code=applied_referral_code or "",
invited_count=int(invited_count or 0),
recent_invited_names=recent_invited_names,
accesses=accesses,
)
async def sync_users_for_telegram(
self,
*,
telegram_id: int,
username: str | None,
first_name: str | None,
last_name: str | None,
language_code: str | None,
) -> list[CachedUserView]:
remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)
async with self._session_factory() as session:
owner = await self._upsert_telegram_user(
session,
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
language_code=language_code,
)
await self._sync_remote_users_for_telegram(
session,
telegram_id=telegram_id,
remote_users=remote_users,
owner=owner,
)
await session.commit()
return await self.get_cached_users_for_telegram(telegram_id)
async def refresh_cached_users_for_telegram(self, *, telegram_id: int) -> list[CachedUserView]:
remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)
async with self._session_factory() as session:
owner = await session.scalar(
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
)
await self._sync_remote_users_for_telegram(
session,
telegram_id=telegram_id,
remote_users=remote_users,
owner=owner,
)
await session.commit()
return await self.get_cached_users_for_telegram(telegram_id)
async def link_user_by_short_uuid(
self,
*,
short_uuid: str,
telegram_id: int,
username: str | None,
first_name: str | None,
last_name: str | None,
language_code: str | None,
) -> CachedUserView:
remote_user = await self._remnawave_client.get_user_by_short_uuid(short_uuid)
if remote_user.telegram_id and remote_user.telegram_id != telegram_id:
raise ValueError("Этот аккаунт уже привязан к другому Telegram ID.")
updated_user = await self._remnawave_client.update_user(
{
"uuid": str(remote_user.uuid),
"telegramId": telegram_id,
}
)
async with self._session_factory() as session:
owner = await self._upsert_telegram_user(
session,
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
language_code=language_code,
)
await self._upsert_remnawave_user(session, updated_user, owner)
if self._settings.sync_subscription_history:
history = await self._remnawave_client.get_user_subscription_history(
str(updated_user.uuid)
)
await self._upsert_history(session, str(updated_user.uuid), history)
await session.commit()
user = await self.get_cached_user_by_uuid(str(updated_user.uuid), include_history=True)
if user is None:
raise ValueError("Не удалось получить локальный кэш пользователя после привязки.")
return user
async def sync_user_by_identifier(
self,
*,
identifier: str,
include_history: bool = True,
) -> CachedUserView:
resolved = await self._remnawave_client.resolve_user(identifier)
remote_user = await self._remnawave_client.get_user_by_uuid(str(resolved.uuid))
history = None
if include_history and self._settings.sync_subscription_history:
history = await self._remnawave_client.get_user_subscription_history(str(resolved.uuid))
async with self._session_factory() as session:
await self._upsert_remnawave_user(session, remote_user, owner=None)
if history is not None:
await self._upsert_history(session, str(resolved.uuid), history)
await session.commit()
user = await self.get_cached_user_by_uuid(str(resolved.uuid), include_history=include_history)
if user is None:
raise ValueError("Не удалось получить локальный кэш пользователя после синхронизации.")
return user
async def sync_all_users(self, batch_size: int) -> int:
start = 0
total_synced = 0
while True:
page = await self._remnawave_client.get_all_users(start=start, size=batch_size)
if not page.users:
break
async with self._session_factory() as session:
for remote_user in page.users:
await self._upsert_remnawave_user(session, remote_user, owner=None)
total_synced += 1
await session.commit()
start += len(page.users)
if start >= page.total:
break
return total_synced
async def get_cached_users_for_telegram(self, telegram_id: int) -> list[CachedUserView]:
async with self._session_factory() as session:
rows = await session.execute(
select(RemnawaveUser, InternalSquad.name)
.outerjoin(
RemnawaveUserInternalSquad,
RemnawaveUserInternalSquad.user_id == RemnawaveUser.id,
)
.outerjoin(
InternalSquad,
InternalSquad.uuid == RemnawaveUserInternalSquad.squad_uuid,
)
.where(RemnawaveUser.telegram_id == telegram_id)
.order_by(RemnawaveUser.username.asc())
)
return self._build_views(rows.all())
async def get_cached_user_by_uuid(
self,
user_uuid: str,
*,
include_history: bool = False,
) -> CachedUserView | None:
async with self._session_factory() as session:
rows = await session.execute(
select(RemnawaveUser, InternalSquad.name)
.outerjoin(
RemnawaveUserInternalSquad,
RemnawaveUserInternalSquad.user_id == RemnawaveUser.id,
)
.outerjoin(
InternalSquad,
InternalSquad.uuid == RemnawaveUserInternalSquad.squad_uuid,
)
.where(RemnawaveUser.rw_uuid == user_uuid)
)
views = self._build_views(rows.all())
if not views:
return None
view = views[0]
if include_history:
history_rows = await session.execute(
select(SubscriptionRequestLog)
.where(SubscriptionRequestLog.user_uuid == user_uuid)
.order_by(SubscriptionRequestLog.request_at.desc())
.limit(5)
)
view.recent_requests = list(history_rows.scalars().all())
return view
async def _upsert_telegram_user(
self,
session: AsyncSession,
*,
telegram_id: int,
username: str | None,
first_name: str | None,
last_name: str | None,
language_code: str | None,
) -> TelegramUser:
record = await session.scalar(
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
)
if record is None:
record = TelegramUser(telegram_id=telegram_id)
session.add(record)
record.username = username
record.first_name = first_name
record.last_name = last_name
record.language_code = language_code
record.is_admin = self._settings.is_admin(telegram_id)
record.last_seen_at = utcnow()
record.updated_at = utcnow()
await session.flush()
return record
async def _sync_remote_users_for_telegram(
self,
session: AsyncSession,
*,
telegram_id: int,
remote_users: list[RemoteRemnawaveUser],
owner: TelegramUser | None,
) -> None:
active_uuids = {str(remote_user.uuid) for remote_user in remote_users}
for remote_user in remote_users:
await self._upsert_remnawave_user(session, remote_user, owner)
filters = [RemnawaveUser.telegram_id == telegram_id]
if owner is not None:
filters.append(RemnawaveUser.owner_telegram_user_id == owner.id)
stale_records = await session.scalars(
select(RemnawaveUser).where(or_(*filters))
)
for record in stale_records:
if record.rw_uuid in active_uuids:
continue
if record.telegram_id == telegram_id:
record.telegram_id = None
if owner is not None and record.owner_telegram_user_id == owner.id:
record.owner_telegram_user_id = None
record.synced_at = utcnow()
async def _upsert_remnawave_user(
self,
session: AsyncSession,
remote_user: RemoteRemnawaveUser,
owner: TelegramUser | None,
) -> RemnawaveUser:
record = await session.scalar(
select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(remote_user.uuid))
)
if record is None:
record = RemnawaveUser(rw_uuid=str(remote_user.uuid))
session.add(record)
owner_id = owner.id if owner is not None else await self._find_owner_id(session, remote_user.telegram_id)
record.owner_telegram_user_id = owner_id
record.rw_id = remote_user.id
record.short_uuid = remote_user.short_uuid
record.username = remote_user.username
record.status = remote_user.status
record.traffic_limit_bytes = remote_user.traffic_limit_bytes
record.used_traffic_bytes = remote_user.user_traffic.used_traffic_bytes
record.lifetime_used_traffic_bytes = remote_user.user_traffic.lifetime_used_traffic_bytes
record.expire_at = self._normalize_datetime(remote_user.expire_at)
record.telegram_id = remote_user.telegram_id
record.email = remote_user.email
record.description = remote_user.description
record.tag = remote_user.tag
record.hwid_device_limit = remote_user.hwid_device_limit
record.external_squad_uuid = self._maybe_str(remote_user.external_squad_uuid)
record.trojan_password = remote_user.trojan_password
record.vless_uuid = str(remote_user.vless_uuid)
record.ss_password = remote_user.ss_password
record.last_triggered_threshold = remote_user.last_triggered_threshold
record.sub_revoked_at = self._normalize_datetime(remote_user.sub_revoked_at)
record.sub_last_user_agent = remote_user.sub_last_user_agent
record.sub_last_opened_at = self._normalize_datetime(remote_user.sub_last_opened_at)
record.last_traffic_reset_at = self._normalize_datetime(remote_user.last_traffic_reset_at)
record.subscription_url = remote_user.subscription_url
record.online_at = self._normalize_datetime(remote_user.user_traffic.online_at)
record.first_connected_at = self._normalize_datetime(remote_user.user_traffic.first_connected_at)
record.last_connected_node_uuid = self._maybe_str(remote_user.user_traffic.last_connected_node_uuid)
record.remote_created_at = self._normalize_datetime(remote_user.created_at)
record.remote_updated_at = self._normalize_datetime(remote_user.updated_at)
record.synced_at = utcnow()
await session.flush()
await session.execute(
delete(RemnawaveUserInternalSquad).where(
RemnawaveUserInternalSquad.user_id == record.id
)
)
for squad in remote_user.active_internal_squads:
squad_uuid = str(squad.uuid)
squad_record = await session.get(InternalSquad, squad_uuid)
if squad_record is None:
squad_record = InternalSquad(uuid=squad_uuid, name=squad.name, synced_at=utcnow())
session.add(squad_record)
else:
squad_record.name = squad.name
squad_record.synced_at = utcnow()
session.add(
RemnawaveUserInternalSquad(
user_id=record.id,
squad_uuid=squad_uuid,
)
)
await session.flush()
return record
async def _upsert_history(
self,
session: AsyncSession,
user_uuid: str,
history: SubscriptionRequestHistory,
) -> None:
for item in history.records:
existing = await session.scalar(
select(SubscriptionRequestLog).where(
SubscriptionRequestLog.remote_id == item.id
)
)
if existing is None:
existing = SubscriptionRequestLog(remote_id=item.id, user_uuid=user_uuid)
session.add(existing)
existing.user_uuid = user_uuid
existing.request_at = self._normalize_datetime(item.request_at)
existing.request_ip = item.request_ip
existing.user_agent = item.user_agent
existing.synced_at = utcnow()
async def _upsert_referral_invite(
self,
session: AsyncSession,
*,
invited: TelegramUser,
referred_by_telegram_id: int | None,
) -> bool:
if referred_by_telegram_id is None or referred_by_telegram_id == invited.telegram_id:
return False
existing = await session.scalar(
select(ReferralInvite).where(
ReferralInvite.invited_telegram_id == invited.telegram_id
)
)
if existing is not None:
return False
display_name = " ".join(
part for part in [invited.first_name, invited.last_name] if part
).strip() or invited.username or str(invited.telegram_id)
session.add(
ReferralInvite(
inviter_telegram_id=referred_by_telegram_id,
invited_telegram_id=invited.telegram_id,
invited_username=invited.username,
invited_display_name=display_name,
)
)
return True
async def _get_or_create_referral_code(
self,
session: AsyncSession,
*,
telegram_id: int,
) -> str:
existing = await session.scalar(
select(ReferralCode).where(ReferralCode.telegram_id == telegram_id)
)
if existing is not None:
return existing.code
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
while True:
code = "".join(secrets.choice(alphabet) for _ in range(8))
duplicate = await session.scalar(
select(ReferralCode.id).where(ReferralCode.code == code)
)
if duplicate is None:
session.add(ReferralCode(telegram_id=telegram_id, code=code))
await session.flush()
return code
async def _find_inviter_by_referral_code(
self,
session: AsyncSession,
referred_by_referral_code: str | None,
) -> int | None:
raw_code = (referred_by_referral_code or "").strip().upper()
if not raw_code:
return None
return await session.scalar(
select(ReferralCode.telegram_id).where(ReferralCode.code == raw_code)
)
async def _find_owner_id(self, session: AsyncSession, telegram_id: int | None) -> int | None:
if telegram_id is None:
return None
return await session.scalar(
select(TelegramUser.id).where(TelegramUser.telegram_id == telegram_id)
)
@staticmethod
def _normalize_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value
return value.astimezone(timezone.utc).replace(tzinfo=None)
@staticmethod
def _maybe_str(value: object | None) -> str | None:
return str(value) if value is not None else None
@staticmethod
def _build_views(rows: list[tuple[RemnawaveUser, str | None]]) -> list[CachedUserView]:
view_map: dict[int, CachedUserView] = {}
for user, squad_name in rows:
view = view_map.setdefault(user.id, CachedUserView(record=user))
if squad_name and squad_name not in view.internal_squads:
view.internal_squads.append(squad_name)
return list(view_map.values())

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

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

182
app/utils/formatters.py Normal file
View File

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

BIN
assets/Thumbs.db Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
assets/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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
View 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:

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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": "🇰🇿 Қазақша"
}
}

View File

@@ -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": "🇰🇿 Қазақша"
}
}

View File

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

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

View File

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

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

View 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

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