rewrite v3

This commit is contained in:
2026-10-28 13:01:59 +03:00
parent da24ed8ba7
commit 372781b956
15 changed files with 527 additions and 1158 deletions
+31 -173
View File
@@ -1,190 +1,48 @@
# =========================
# Telegram bot
# =========================
# Токен бота от BotFather
BOT_TOKEN=1234567890:REPLACE_WITH_BOT_TOKEN
# Telegram ID администраторов через запятую
# Пример: 123456789,987654321
BOT_ADMIN_IDS=123456789
# Telegram ID модераторов через запятую
# Модераторы могут отвечать на тикеты и подтверждать оплаты
# Copy to .env, then set YOUR credentials. Do not share .env.
BOT_TOKEN=
BOT_ADMIN_IDS=
BOT_MODERATOR_IDS=
# Прокси для доступа к Telegram Bot API, если хост не видит api.telegram.org напрямую
# Подходят обычные URL: http://... или socks5://...
BOT_BRAND_NAME="Example VPN"
BOT_PUBLIC_USERNAME=
BOT_SUPPORT_URL=
BOT_TERMS_URL=
BOT_SUPPORT_TICKET_CHAT_ID=
BOT_SUPPORT_TICKET_THREAD_ID=0
BOT_START_IMAGE_ENABLED=false
BOT_START_IMAGE_PATH=
TELEGRAM_PROXY_URL=
# Название бренда в пользовательской панели
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_API_VERSION=3
REMNAWAVE_API_TOKEN=
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_PASSWORD=
CREATE_DATABASE_ON_START=false
CREATE_TABLES_ON_START=true
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_SUCCESS_TEXT=Доступ выдан. Сохраните ссылку и используйте её для подключения.
PAYMENT_REVIEW_LINK=https://t.me/c/1234567890/56/57
# Альтернативный способ указать chat id review-чата вручную
# Если заполнен, имеет приоритет над PAYMENT_REVIEW_LINK
# Example price in rubles; configure your own products and payment details.
PAYMENT_PLANS=30:100
PAYMENT_TRANSFER_TEXT="Укажите собственные реквизиты для оплаты."
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_INTERNAL_SQUAD_UUIDS=
PAYMENT_VPN_SQUAD_UUIDS=
PAYMENT_WHITE_SQUAD_UUIDS=
PAYMENT_EXTERNAL_SQUAD_UUID=
# Текст для пользователя, если оплата отклонена или доступ не выдался
PAYMENT_SUPPORT_TEXT=Если оплата прошла, но доступ не выдался, напишите в поддержку.
# Префикс логина Remnawave
# Итоговый логин будет вида Oreol-<telegram_id>-<username>
PAYMENT_USERNAME_PREFIX=Oreol
# Tag для пользователей, создаваемых ботом в Remnawave
PAYMENT_USERNAME_PREFIX=Example
PAYMENT_USER_TAG=BOT
BROADCAST_RESULTS_LINK=
BROADCAST_RESULTS_CHAT_ID=
BROADCAST_RESULTS_THREAD_ID=0
REFERRAL_DISCOUNT_PERCENT=5
REFERRAL_BONUS_DAYS=7
SYNC_SUBSCRIPTION_HISTORY=true
SYNC_BATCH_SIZE=100
LOG_LEVEL=INFO
+4 -12
View File
@@ -1,16 +1,8 @@
.env
.env.local
.env.*
!.env.example
app/legacy_user_ids.json
.venv/
.analysis-venv/
.verify-venv/
.ticket-verify-venv/
__pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
*.py[cod]
*.pyo
*.pyd
*.egg-info/
build/
dist/
*.log
+21 -800
View File
@@ -1,817 +1,38 @@
# OREOL VPN Bot
# Telegram-бот для Remnawave v3
Telegram-бот на `aiogram 3` с интеграцией в `Remnawave API`, локальным кэшем в `MariaDB`, пользовательской панелью, тикетами поддержки, реферальной системой и ручной оплатой с подтверждением админом.
Исходники aiogram 3 + MariaDB с адаптацией к числовым ID Remnawave v3. Ручная оплата с проверкой администратором, поддержка, рефералы и кэш подписок.
Этот README написан как рабочий handoff-документ: по нему можно поднять проект, понять текущую архитектуру и продолжить разработку в новой сессии без восстановления контекста по кускам.
## Перед запуском
## Что умеет бот
Это архив исходников, а не готовая настроенная установка. Токены, реальные пользователи, рабочая карта ID, реквизиты и серверные настройки исключены. Нейтральные цены и тексты — примеры; задайте свои.
- Показывает стартовую панель пользователя через `/start`
- Подтягивает аккаунты Remnawave по `telegramId`
- Хранит локальный кэш пользователей и истории подписок в MariaDB
- Поддерживает привязку существующего аккаунта Remnawave по `short_uuid`
- Показывает профиль, статус подписки, срок и трафик
- Работает с реферальными кодами и скидкой перед оплатой
- Поддерживает ручную оплату переводом с отправкой чека в бот
- Отправляет чек в отдельный Telegram-канал/топик на проверку
- Даёт админу или модератору подтвердить или отклонить оплату
- После подтверждения создаёт или продлевает доступ в Remnawave
- Поддерживает тикеты в отдельный канал и ответы только от админа/модератора
- Показывает админам отдельную Telegram-панель с метриками и быстрыми действиями
Зависимости: Python 3.12+ (Dockerfile использует 3.13), MariaDB, доступ к API Remnawave, собственный Telegram-бот и его токен. Полный список библиотек находится в pyproject.toml. Для нового окружения версии зависимостей разрешаются из указанных диапазонов; серверный образ и lock-файл не приложены.
## Текущий сценарий оплаты
Сейчас в проекте не используется 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`
- форматирование дат
- форматирование трафика
- форматирование карточек доступа
## Структура проекта
```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
```bash
docker compose up --build
```
Это полный запуск вместе с 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
```
На Windows PowerShell:
1. Создайте отдельную БД MariaDB и пользователя с правами на неё. Укажите собственные DB_*.
2. Скопируйте .env.example в .env и заполните токены, адрес панели, Telegram ID администратора и настройки чатов/оплаты/сквадов. Не отправляйте заполненный .env другим людям.
3. Подготовьте карту ID, описанную ниже. Без неё режим v3 не запустится.
4. В терминале из каталога проекта:
```powershell
Copy-Item .env.example .env
python -m venv .venv
.venv\Scripts\python.exe -m pip install .
.venv\Scripts\python.exe main.py
```
2. Открыть `.env` и заполнить обязательные значения:
Для Linux используйте `.venv/bin/python`. БД должна быть создана заранее: в примере CREATE_DATABASE_ON_START=false, CREATE_TABLES_ON_START=true.
- `BOT_TOKEN`
- `BOT_ADMIN_IDS`
- `REMNAWAVE_BASE_URL`
- `REMNAWAVE_API_TOKEN`
- `DB_NAME`
- `DB_USER`
- `DB_PASSWORD`
- `DB_ROOT_PASSWORD`
- `PAYMENT_INTERNAL_SQUAD_UUIDS`
Для разработки: `python -m pip install ".[dev]"` и `python -m pytest`. Исходные тесты включены; при изменении примерных настроек сверяйте ожидаемые значения тестов.
Обычно ещё сразу настраивают:
## Обязательная карта ID для v3
- `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`
`app/legacy_user_ids.json` специально НЕ включён: он содержал реальные данные пользователей. В текущем коде v3 требует непустой JSON-массив соответствий полей `id` (числовой ID пользователя панели) и `uuid` (прежний локальный UUID того же пользователя). Карта должна принадлежать вашей панели и точно совпадать с существующим кэшем бота. Код проверяет соответствия и исторические записи перед началом polling. Не копируйте чужую карту, не подставляйте случайные UUID и не удаляйте защиту ради запуска.
#### Важный момент по базе данных в Docker
При миграции собственной установки v2→v3 сохраните свои точные соответствия UUID→числовой ID ДО миграции и установите карту на место указанного файла; храните её вместе с резервной копией БД.
При запуске через `docker compose` контейнер бота сам получает:
Для полностью новой v3-установки этот миграционный вариант кода тоже требует непустую карту. Готового безопасного начального seed в архиве нет. Перед первым запуском понадобится отдельно согласованная инициализация идентификаторов для вашей панели/пустой БД либо доработка режима чистой установки. Пустой массив не принимается. Архив не следует считать готовым к запуску на чистой v3-панели.
```env
DB_HOST=db
DB_PORT=3306
```
## Что отсутствует
Это уже задано в `docker-compose.yml`. В `.env` для Docker главное задать:
Рабочий .env, карта пользователей, БД/дампы, логи, резервные копии и серверные токены исключены. Серверный docker-compose.yml, настройки mihomo/subconverter, изображения и другие внешние assets локально не выгружались и в архиве отсутствуют. Стартовая картинка в примере выключена; при необходимости добавьте свою и настройте путь. Dockerfile приложен, но сам по себе не создаёт БД и не заменяет серверный compose.
- `DB_NAME`
- `DB_USER`
- `DB_PASSWORD`
- `DB_ROOT_PASSWORD`
Если вы запускаете проект только в Docker, `DB_HOST` и `DB_PORT` можно не менять вручную.
`DB_ROOT_PASSWORD` нужен только контейнеру MariaDB. Сам бот подключается к базе исключительно через:
- `DB_USER`
- `DB_PASSWORD`
Это важно:
- `DB_ROOT_PASSWORD` не используется приложением для SQLAlchemy-подключения
- если в `.env` указать `DB_USER=root`, но оставить `DB_PASSWORD` пустым, бот попытается зайти как `root` без пароля и упадёт с `Access denied`
Для production рекомендуется не использовать `root` для бота, а создать отдельного пользователя БД через:
- `DB_USER`
- `DB_PASSWORD`
MariaDB в текущем `docker-compose.yml` не публикуется наружу на хост, чтобы база не торчала во внешний интернет по `3306`. Бот подключается к ней по внутренней Docker-сети через hostname `db`.
#### Первый запуск
Запустите сборку и контейнеры:
```bash
docker compose up -d --build
```
Будут подняты два сервиса:
- `db` — MariaDB 11.7
- `bot` — приложение на Python
Полный сценарий для Ubuntu Server обычно выглядит так:
```bash
git clone <URL_репозитория> telegabot
cd telegabot
cp .env.example .env
nano .env
docker compose up -d --build
```
#### Как проверить, что всё поднялось
Посмотреть список контейнеров:
```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`
Если видите ошибку вида:
```text
Access denied for user 'root' ... (using password: NO)
```
проверьте:
- не стоит ли в `.env` `DB_USER=root`
- не пустой ли `DB_PASSWORD`
- совпадают ли `DB_USER` и `DB_PASSWORD` с пользователем MariaDB, который был создан при первом старте контейнера
Если контейнер `db` уже один раз поднимался с неправильными DB-переменными, volume MariaDB мог сохранить старого пользователя и старый пароль. Тогда есть два варианта:
- если данных ещё не жалко: `docker compose down -v` и затем `docker compose up -d --build`
- если данные нужно сохранить: зайти в MariaDB и вручную создать/выдать права нужному пользователю
Если хотите открыть 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`
- открытие раздела подписки через кнопку в панели
- ввод реферального кода
- отправку чека
- подтверждение оплаты модератором
- выдачу ссылки пользователю
- поддержку через тикет
Исходные файлы сохранены; в экспортной копии заменены личная ссылка Telegram в настройках, бренд/префикс по умолчанию и примерные цены. Бизнес-логика и обязательная проверка карты не менялись.
+7 -6
View File
@@ -48,7 +48,7 @@ class Settings(BaseSettings):
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_brand_name: str = Field(default="Example 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")
@@ -68,6 +68,7 @@ class Settings(BaseSettings):
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_version: int = Field(default=2, ge=2, le=3, validation_alias="REMNAWAVE_API_VERSION")
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(
@@ -97,7 +98,7 @@ class Settings(BaseSettings):
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
payment_plans_raw: str = Field(
default="30:250,180:600,365:1000",
default="30:100",
validation_alias="PAYMENT_PLANS",
)
payment_product_plans_raw: str = Field(
@@ -122,7 +123,7 @@ class Settings(BaseSettings):
validation_alias="PAYMENT_REVIEW_THREAD_ID",
)
broadcast_results_link: str = Field(
default="https://t.me/c/3646494169/67/68",
default="",
validation_alias="BROADCAST_RESULTS_LINK",
)
broadcast_results_chat_id_raw: str = Field(
@@ -143,7 +144,7 @@ class Settings(BaseSettings):
)
payment_plan_code: str = Field(default="vpn_30d", validation_alias="PAYMENT_PLAN_CODE")
payment_plan_title: str = Field(
default="OREOL VPN на 30 дней",
default="Example VPN на 30 дней",
validation_alias="PAYMENT_PLAN_TITLE",
)
payment_plan_description: str = Field(
@@ -184,7 +185,7 @@ class Settings(BaseSettings):
validation_alias="PAYMENT_SUPPORT_TEXT",
)
payment_username_prefix: str = Field(
default="Oreol",
default="Example",
validation_alias="PAYMENT_USERNAME_PREFIX",
)
payment_user_tag: str = Field(default="BOT", validation_alias="PAYMENT_USER_TAG")
@@ -323,7 +324,7 @@ class Settings(BaseSettings):
normalized = "".join(
ch for ch in self.payment_username_prefix.strip() if ch.isalnum() or ch in "_-"
)
return normalized[:12] or "Oreol"
return normalized[:12] or "Example"
@cached_property
def payment_user_tag_normalized(self) -> str | None:
+19
View File
@@ -2,6 +2,8 @@ import asyncio
import logging
from pathlib import Path
from sqlalchemy import select
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
@@ -11,6 +13,8 @@ from aiogram.types import BotCommand
from app.bot.handlers.main import build_router
from app.config import Settings
from app.db.models import RemnawaveUser
from app.services.remnawave_identity import load_legacy_user_ids
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
@@ -55,12 +59,27 @@ async def main() -> None:
if settings.create_tables_on_start:
await init_db(engine)
legacy_user_ids = None
if settings.remnawave_api_version == 3:
identity_map = Path(__file__).with_name("legacy_user_ids.json")
legacy_user_ids = load_legacy_user_ids(identity_map)
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,
api_version=settings.remnawave_api_version,
legacy_user_ids=legacy_user_ids,
)
if settings.remnawave_api_version == 3:
async with session_factory() as session:
cached_identities = await session.execute(
select(RemnawaveUser.rw_id, RemnawaveUser.rw_uuid, RemnawaveUser.short_uuid)
)
historical_count = await remnawave_client.validate_cached_identities(
cached_identities.all()
)
logger.info("Validated Remnawave identities; preserved %s deleted historical cache rows", historical_count)
sync_service = SyncService(
settings=settings,
session_factory=session_factory,
+6 -6
View File
@@ -71,7 +71,7 @@ class BotConfigSnapshot:
normalized = "".join(
ch for ch in self.payment_username_prefix.strip() if ch.isalnum() or ch in "_-"
)
return normalized[:12] or "Oreol"
return normalized[:12] or "Example"
@property
def payment_user_tag_normalized(self) -> str | None:
@@ -323,7 +323,7 @@ class BotConfigService(StaticBotConfigService):
section="brand",
prompt="Введите новое название бренда.",
description="Используется в главной панели и пользовательских сообщениях.",
placeholder="OREOL VPN",
placeholder="Example VPN",
),
"bot_public_username": BotConfigFieldSpec(
key="bot_public_username",
@@ -331,7 +331,7 @@ class BotConfigService(StaticBotConfigService):
section="brand",
prompt="Введите username бота без @. Для очистки отправьте `-`.",
description="Нужен для генерации реферальных ссылок.",
placeholder="oreol_vpn_bot",
placeholder="example_vpn_bot",
),
"bot_start_image_enabled": BotConfigFieldSpec(
key="bot_start_image_enabled",
@@ -450,7 +450,7 @@ class BotConfigService(StaticBotConfigService):
section="links",
prompt="Введите приватную ссылку на чат/топик, куда будут приходить ответы опросов. Для очистки отправьте `-`.",
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
placeholder="https://t.me/c/3646494169/67/68",
placeholder="https://t.me/c/1234567890/67/68",
),
"payment_transfer_text": BotConfigFieldSpec(
key="payment_transfer_text",
@@ -458,7 +458,7 @@ class BotConfigService(StaticBotConfigService):
section="texts",
prompt="Введите текст с реквизитами или инструкцией перевода.",
description="Показывается пользователю после выбора тарифа.",
placeholder="Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN",
placeholder="Карта 0000 0000 0000 0000; банк Пример; получатель Example VPN",
),
"payment_support_text": BotConfigFieldSpec(
key="payment_support_text",
@@ -514,7 +514,7 @@ class BotConfigService(StaticBotConfigService):
section="provisioning",
prompt="Введите префикс логина для новых пользователей Remnawave.",
description="Итоговый логин строится как `<prefix>-<telegram_id>-<name>`.",
placeholder="Oreol",
placeholder="Example",
),
"payment_user_tag": BotConfigFieldSpec(
key="payment_user_tag",
+1 -1
View File
@@ -1285,7 +1285,7 @@ class PaymentService:
base_name = username or first_name or "user"
normalized_name = self._normalize_provision_name(base_name)
prefix = config.payment_username_prefix_normalized or "Oreol"
prefix = config.payment_username_prefix_normalized or "Example"
unique_suffix = order_uuid.replace("-", "")[:6]
provision_username = f"{prefix}-{telegram_id}-{unique_suffix}-{normalized_name}"
return provision_username[:36]
+117 -26
View File
@@ -1,11 +1,13 @@
from __future__ import annotations
import re
from uuid import UUID
from collections.abc import Mapping
from typing import Any
import httpx
from app.services.remnawave_identity import deterministic_user_uuid, validate_cached_identities as validate_identity_cache
from app.schemas.remnawave import HwidDevicesResponse, PaginatedUsers, RemnawaveUser, ResolvedUser, SubscriptionRequestHistory
@@ -35,7 +37,20 @@ class RemnawaveApiClient:
caddy_api_key: str = "",
timeout_seconds: float = 20.0,
client: httpx.AsyncClient | None = None,
api_version: int = 2,
legacy_user_ids: dict[int, str] | None = None,
) -> None:
if api_version not in (2, 3):
raise ValueError("Supported Remnawave API versions: 2, 3")
self._api_version = api_version
self._local_uuid_by_id = {
int(user_id): str(UUID(local_uuid))
for user_id, local_uuid in (legacy_user_ids or {}).items()
}
self._id_by_local_uuid = {value: key for key, value in self._local_uuid_by_id.items()}
self._username_by_id: dict[int, str] = {}
if len(self._id_by_local_uuid) != len(self._local_uuid_by_id):
raise ValueError("Duplicate UUID in legacy identity map")
self._managed_client = client is None
self._client = client or httpx.AsyncClient(
base_url=self.normalize_base_url(base_url),
@@ -83,11 +98,24 @@ class RemnawaveApiClient:
if self._managed_client:
await self._client.aclose()
async def validate_cached_identities(self, rows: list[tuple[int, str] | tuple[int, str, str]]) -> int:
if self._api_version != 3:
raise ValueError("Remote identity validation requires Remnawave v3")
users = await self._stream_users()
return validate_identity_cache(
self._local_uuid_by_id, rows,
remote_identities=[(user.id, str(user.uuid), user.short_uuid) for user in users],
)
async def get_users_by_telegram_id(self, telegram_id: int) -> list[RemnawaveUser]:
if self._api_version == 3:
return await self._stream_users(telegram_id=telegram_id)
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:
if self._api_version == 3:
user_uuid = str(await self._remote_id(user_uuid))
payload = await self._request_json("GET", f"users/{user_uuid}")
return RemnawaveUser.model_validate(payload)
@@ -100,10 +128,24 @@ class RemnawaveApiClient:
return RemnawaveUser.model_validate(payload)
async def create_user(self, body: dict[str, Any]) -> RemnawaveUser:
if self._api_version == 3 and "uuid" in body:
raise ValueError("Remnawave v3 does not accept custom user UUIDs")
payload = await self._request_json("POST", "users", json=body)
return RemnawaveUser.model_validate(payload)
async def update_user(self, body: dict[str, Any]) -> RemnawaveUser:
if self._api_version == 3 and "uuid" in body:
body = dict(body)
local_uuid = body.pop("uuid")
user_id = await self._remote_id(str(local_uuid))
if "id" in body and body["id"] != user_id:
raise ValueError("Conflicting user identifiers")
if "username" in body:
if user_id not in self._username_by_id:
await self.get_user_by_uuid(str(user_id))
if body.pop("username") != self._username_by_id[user_id]:
raise ValueError("Remnawave v3 cannot rename a user with PATCH /users")
body["id"] = user_id
payload = await self._request_json("PATCH", "users", json=body)
return RemnawaveUser.model_validate(payload)
@@ -116,6 +158,8 @@ class RemnawaveApiClient:
return PaginatedUsers.model_validate(payload)
async def get_user_subscription_history(self, user_uuid: str) -> SubscriptionRequestHistory:
if self._api_version == 3:
user_uuid = str(await self._remote_id(user_uuid))
payload = await self._request_json(
"GET",
f"users/{user_uuid}/subscription-request-history",
@@ -126,7 +170,8 @@ class RemnawaveApiClient:
body: dict[str, Any]
if UUID_RE.match(identifier):
body = {"uuid": identifier}
body = ({"id": await self._remote_id(identifier)}
if self._api_version == 3 else {"uuid": identifier})
elif identifier.isdigit():
body = {"id": int(identifier)}
else:
@@ -146,22 +191,28 @@ class RemnawaveApiClient:
return ResolvedUser.model_validate(payload)
async def get_user_hwid_devices(self, user_uuid: str) -> HwidDevicesResponse:
if self._api_version == 3:
user_uuid = str(await self._remote_id(user_uuid))
payload = await self._request_json("GET", f"hwid/devices/{user_uuid}")
return HwidDevicesResponse.model_validate(payload)
async def delete_user_hwid_device(self, *, user_uuid: str, hwid: str) -> HwidDevicesResponse:
user_field = ({"userId": await self._remote_id(user_uuid)}
if self._api_version == 3 else {"userUuid": user_uuid})
payload = await self._request_json(
"POST",
"hwid/devices/delete",
json={"userUuid": user_uuid, "hwid": hwid},
json={**user_field, "hwid": hwid},
)
return HwidDevicesResponse.model_validate(payload)
async def delete_all_user_hwid_devices(self, user_uuid: str) -> HwidDevicesResponse:
user_field = ({"userId": await self._remote_id(user_uuid)}
if self._api_version == 3 else {"userUuid": user_uuid})
payload = await self._request_json(
"POST",
"hwid/devices/delete-all",
json={"userUuid": user_uuid},
json=user_field,
)
return HwidDevicesResponse.model_validate(payload)
@@ -173,28 +224,12 @@ class RemnawaveApiClient:
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
) -> Any:
try:
response = await self._client.request(
method=method,
url=path.lstrip("/"),
params=params,
json=json,
)
except httpx.TimeoutException as exc:
raise RemnawaveApiError(
status_code=0,
message=f"Remnawave API timeout: {exc}",
) from exc
except httpx.NetworkError as exc:
raise RemnawaveApiError(
status_code=0,
message=f"Remnawave API network error: {exc}",
) from exc
except httpx.HTTPError as exc:
raise RemnawaveApiError(
status_code=0,
message=f"Remnawave API HTTP error: {exc}",
) from exc
response = await self._client.request(
method=method,
url=path.lstrip("/"),
params=params,
json=json,
)
if response.is_error:
raise self._build_error(response)
@@ -204,7 +239,63 @@ class RemnawaveApiClient:
except ValueError:
return response.text
return self.unwrap_payload(payload)
payload = self.unwrap_payload(payload)
return self._normalize_v3(payload) if self._api_version == 3 else payload
def _local_uuid(self, user_id: int) -> str:
"""Keep bot DB references stable; these UUIDs are never sent to API v3."""
user_id = int(user_id)
if user_id not in self._local_uuid_by_id:
# Deterministic local identity for accounts created after migration.
self._local_uuid_by_id[user_id] = deterministic_user_uuid(user_id)
local_uuid = self._local_uuid_by_id[user_id]
self._id_by_local_uuid[local_uuid] = user_id
return local_uuid
def _normalize_v3(self, payload: Any) -> Any:
if isinstance(payload, list):
return [self._normalize_v3(item) for item in payload]
if not isinstance(payload, dict):
return payload
result = {key: self._normalize_v3(value) for key, value in payload.items()}
if all(key in result for key in ("id", "username", "shortUuid")):
result["uuid"] = self._local_uuid(result["id"])
self._username_by_id[int(result["id"])] = result["username"]
if "userId" in result and result["userId"] is not None:
result["userUuid"] = self._local_uuid(result["userId"])
return result
async def _stream_users(self, *, telegram_id: int | None = None) -> list[RemnawaveUser]:
params: dict[str, Any] = {"size": 250}
if telegram_id is not None:
params["telegramId"] = telegram_id
users: list[RemnawaveUser] = []
seen_cursors: set[str] = set()
while True:
payload = await self._request_json("GET", "users/stream", params=params)
users.extend(RemnawaveUser.model_validate(item) for item in payload["users"])
if not payload.get("hasMore"):
return users
cursor = payload.get("nextCursor")
if cursor is None or str(cursor) in seen_cursors:
raise RemnawaveApiError(status_code=502, message="Invalid users stream cursor")
seen_cursors.add(str(cursor))
params["cursor"] = cursor
async def _remote_id(self, identifier: str) -> int:
if identifier.isdigit():
return int(identifier)
try:
local_uuid = str(UUID(identifier))
except ValueError as exc:
raise RemnawaveApiError(status_code=404, message="Invalid user identifier") from exc
if local_uuid not in self._id_by_local_uuid:
# Rebuild deterministic aliases after a restart, including an order
# saved before its new user reached the bot's local cache.
await self._stream_users()
if local_uuid not in self._id_by_local_uuid:
raise RemnawaveApiError(status_code=404, message="User not found")
return self._id_by_local_uuid[local_uuid]
def _build_error(self, response: httpx.Response) -> RemnawaveApiError:
try:
+72
View File
@@ -0,0 +1,72 @@
"""Migration identity map validation, before starting Telegram polling."""
from __future__ import annotations
import json
from pathlib import Path
from uuid import UUID, uuid5
from collections.abc import Iterable
def deterministic_user_uuid(user_id: int) -> str:
return str(uuid5(UUID("2a438583-9a10-4b90-831f-7db0432b5578"), str(user_id)))
def load_legacy_user_ids(path: Path) -> dict[int, str]:
entries = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(entries, list) or not entries:
raise ValueError("Legacy Remnawave identity map must be a non-empty list")
result: dict[int, str] = {}
seen_uuids: set[str] = set()
for entry in entries:
user_id = entry["id"]
if not isinstance(user_id, int) or isinstance(user_id, bool) or user_id <= 0:
raise ValueError("Legacy Remnawave user ID must be a positive integer")
local_uuid = str(UUID(entry["uuid"]))
if user_id in result or local_uuid in seen_uuids:
raise ValueError("Duplicate user identity in legacy Remnawave map")
result[user_id] = local_uuid
seen_uuids.add(local_uuid)
return result
def validate_cached_identities(
legacy: dict[int, str],
rows: Iterable[tuple[int, str] | tuple[int, str, str]],
*,
remote_identities: Iterable[tuple[int, str] | tuple[int, str, str]] | None = None,
) -> int:
"""Keep historical deleted cache rows only after a complete remote snapshot.
Never turn a deleted account's UUID into an alias for its old numeric ID.
A reused ID or a UUID assigned to a different existing ID fails closed.
"""
snapshot = None if remote_identities is None else list(remote_identities)
remote = None if snapshot is None else {
item[0]: str(UUID(item[1])) for item in snapshot
}
remote_short_uuids = {} if snapshot is None else {
item[0]: item[2] for item in snapshot if len(item) > 2
}
legacy_ids_by_uuid = {local_uuid: user_id for user_id, local_uuid in legacy.items()}
remote_ids_by_uuid = {} if remote is None else {
local_uuid: user_id for user_id, local_uuid in remote.items()
}
historical_deleted = 0
for row in rows:
user_id, value = row[:2]
local_uuid = str(UUID(value))
if len(row) > 2 and user_id in remote_short_uuids and row[2] != remote_short_uuids[user_id]:
raise ValueError("Remote Remnawave short UUID conflicts with the bot cache")
expected = legacy.get(user_id, deterministic_user_uuid(user_id))
if legacy_ids_by_uuid.get(local_uuid, user_id) != user_id:
raise ValueError("Legacy Remnawave UUID belongs to another cached ID")
if remote_ids_by_uuid.get(local_uuid, user_id) != user_id:
raise ValueError("Remote Remnawave UUID belongs to another cached ID")
if local_uuid == expected:
if remote is not None and user_id in remote and remote[user_id] != local_uuid:
raise ValueError("Remote Remnawave identity conflicts with the bot cache")
continue
if user_id in legacy or remote is None or user_id in remote:
raise ValueError("Legacy Remnawave map does not match the existing bot cache")
historical_deleted += 1
return historical_deleted
-7
View File
@@ -21,7 +21,6 @@ def _make_settings() -> Settings:
payment_plans_raw="30:250,180:600",
payment_transfer_text="Реквизиты",
payment_support_text="Напишите в поддержку",
payment_success_text="Доступ выдан",
payment_plan_traffic_limit_gb=0,
payment_plan_traffic_reset_period="NO_RESET",
payment_internal_squad_uuids_raw="uuid-1",
@@ -52,17 +51,11 @@ async def test_bot_config_service_updates_runtime_snapshot(session_factory) -> N
raw_value="off",
updated_by_telegram_id=1,
)
await service.update_setting(
key="payment_success_text",
raw_value="Новый текст после оплаты",
updated_by_telegram_id=1,
)
snapshot = await service.get_snapshot()
assert snapshot.bot_brand_name == "NEW BRAND"
assert snapshot.referral_enabled is False
assert snapshot.payment_success_text == "Новый текст после оплаты"
assert [(plan.days, plan.amount_rub) for plan in snapshot.payment_plans] == [
(30, 300),
(90, 700),
+4 -4
View File
@@ -15,23 +15,23 @@ def test_database_server_url_excludes_database_name() -> None:
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_link="https://t.me/c/1234567890/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_chat_id == -1001234567890
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_link="https://t.me/c/1234567890/56/57",
payment_review_chat_id_raw="",
payment_review_thread_id=0,
)
assert settings.payment_review_chat_id == -1003646494169
assert settings.payment_review_chat_id == -1001234567890
assert settings.payment_review_message_thread_id == 56
-13
View File
@@ -75,19 +75,6 @@ def test_format_issued_access_contains_subscription_data() -> None:
assert "/me</code>" not in text
def test_format_issued_access_includes_custom_success_text() -> 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",
success_text="Откройте приложение и импортируйте ссылку <важно>.",
)
assert "Откройте приложение и импортируйте ссылку" in text
assert "&lt;важно&gt;" in text
def test_format_issued_access_for_renewal_mentions_extension() -> None:
text = format_issued_access(
subscription_url="https://example.com/sub",
-110
View File
@@ -53,10 +53,6 @@ class _StubRemnawaveClient:
self.users_by_telegram_id = users_by_telegram_id or {}
async def get_user_by_uuid(self, user_uuid: str):
for users in self.users_by_telegram_id.values():
for user in users:
if str(user.uuid) == user_uuid:
return user
raise RemnawaveApiError(status_code=404, message="not found")
async def get_user_by_username(self, username: str):
@@ -414,50 +410,6 @@ async def test_create_order_generates_unique_provision_username(session_factory)
assert second_order.extends_existing_access is False
async def test_create_order_transliterates_russian_first_name_without_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="99999999-9999-9999-9999-999999999999",
user_id=9,
short_uuid="buyer999",
username="oreol-user",
telegram_id=1001,
)
),
sync_service=_StubSyncService(),
)
order = await service.create_order(
telegram_id=1001,
username=None,
first_name="\u0410\u043b\u0435\u043a\u0441\u0435\u0439",
last_name=None,
language_code="ru",
plan_code="30d",
)
assert order.provision_username.startswith("Oreol-1001-")
assert order.provision_username.endswith("-Aleksey")
assert order.provision_username.isascii()
async def test_create_order_marks_existing_access_as_renewal(session_factory) -> None:
service = PaymentService(
settings=Settings.model_construct(
@@ -499,68 +451,6 @@ async def test_create_order_marks_existing_access_as_renewal(session_factory) ->
assert "продлит текущую подписку" in service.build_transfer_text(order=order)
async def test_create_order_targets_external_subscription_linked_by_telegram_id(
session_factory,
) -> None:
existing_remote_user = make_remote_user(
user_uuid="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
user_id=10,
short_uuid="external10",
username="custom-external",
telegram_id=1001,
)
remnawave_client = _StubRemnawaveClient(
created_user=existing_remote_user,
users_by_telegram_id={1001: [existing_remote_user]},
)
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=remnawave_client,
sync_service=_StubSyncService(),
)
order = await service.create_order(
telegram_id=1001,
username="buyer",
first_name="Buyer",
last_name=None,
language_code="ru",
plan_code="30d",
)
assert order.extends_existing_access is True
assert order.provision_username == "custom-external"
async with session_factory() as session:
stored_order = await session.scalar(
select(PaymentOrder).where(PaymentOrder.order_uuid == order.order_uuid)
)
assert stored_order is not None
assert stored_order.remnawave_user_uuid == str(existing_remote_user.uuid)
await service.mark_order_under_review(order_uuid=order.order_uuid, telegram_id=1001)
access = await service.approve_order(order_uuid=order.order_uuid)
assert access.is_renewal is True
assert access.username == "custom-external"
assert remnawave_client.created_bodies == []
assert remnawave_client.updated_bodies[-1]["username"] == "custom-external"
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",
+49
View File
@@ -0,0 +1,49 @@
import json
import pytest
from app.services.remnawave_identity import load_legacy_user_ids, validate_cached_identities, deterministic_user_uuid
LEGACY = 'a1111111-1111-4111-8111-111111111111'
@pytest.mark.parametrize('entries', [[], {}, [{'id': 42, 'uuid': LEGACY}, {'id': 42, 'uuid': LEGACY}], [{'id': 42, 'uuid': LEGACY}, {'id': 43, 'uuid': LEGACY}], [{'id': True, 'uuid': LEGACY}]])
def test_legacy_map_rejects_ambiguous_or_empty_data(tmp_path, entries):
path = tmp_path / 'map.json'
path.write_text(json.dumps(entries))
with pytest.raises(ValueError):
load_legacy_user_ids(path)
def test_missing_legacy_cache_mapping_fails_closed():
with pytest.raises(ValueError, match='cache'):
validate_cached_identities({}, [(42, LEGACY)])
def test_cache_allows_legacy_and_new_deterministic_identity_after_restart():
validate_cached_identities({42: LEGACY}, [(42, LEGACY), (99, deterministic_user_uuid(99))])
def test_deleted_history_requires_complete_snapshot_and_is_preserved():
assert validate_cached_identities({42: LEGACY}, [(7, 'b2222222-2222-4222-8222-222222222222')],
remote_identities=[(42, LEGACY)]) == 1
def test_reused_remote_id_rejects_historical_uuid():
with pytest.raises(ValueError, match='cache'):
validate_cached_identities({}, [(7, LEGACY)], remote_identities=[(7, deterministic_user_uuid(7))])
def test_deleted_cache_uuid_cannot_belong_to_another_legacy_id():
with pytest.raises(ValueError, match='another'):
validate_cached_identities({42: LEGACY}, [(7, LEGACY)], remote_identities=[])
def test_deleted_cache_uuid_cannot_belong_to_another_live_id():
with pytest.raises(ValueError, match='another'):
validate_cached_identities({}, [(7, LEGACY)], remote_identities=[(99, LEGACY)])
def test_matching_seed_does_not_override_conflicting_live_identity():
with pytest.raises(ValueError, match='conflicts'):
validate_cached_identities({42: LEGACY}, [(42, LEGACY)],
remote_identities=[(42, deterministic_user_uuid(42))])
def test_reused_seeded_id_cannot_hide_changed_short_uuid_behind_legacy_alias():
with pytest.raises(ValueError, match='short UUID'):
validate_cached_identities({42: LEGACY}, [(42, LEGACY, 'old-short')],
remote_identities=[(42, LEGACY, 'new-short')])
+196
View File
@@ -0,0 +1,196 @@
import json
import httpx
import pytest
from app.db.models import RemnawaveUser
from app.services.remnawave_client import RemnawaveApiClient, RemnawaveApiError
from tests.helpers import make_remote_user
LEGACY_UUID = 'a1111111-1111-4111-8111-111111111111'
def user_payload(user_id=42):
payload = make_remote_user(
user_uuid=LEGACY_UUID, user_id=user_id, short_uuid=f'short{user_id}',
username=f'user{user_id}', telegram_id=777,
).model_dump(by_alias=True, mode='json')
payload.pop('uuid')
return payload
def client_for(handler, legacy=None):
http = httpx.AsyncClient(base_url='https://panel.test/api/', transport=httpx.MockTransport(handler))
api = RemnawaveApiClient(base_url='https://panel.test', api_token='', client=http,
api_version=3, legacy_user_ids=legacy or {42: LEGACY_UUID})
return api, http
async def test_telegram_search_uses_filtered_stream_and_all_pages():
requests = []
def handler(request):
requests.append(request)
assert request.url.path == '/api/users/stream'
assert request.url.params['telegramId'] == '777'
next_page = 'cursor' in request.url.params
return httpx.Response(200, json={'response': {
'users': [user_payload(43 if next_page else 42)],
'hasMore': not next_page, 'nextCursor': None if next_page else '42',
}})
api, http = client_for(handler)
async with http:
users = await api.get_users_by_telegram_id(777)
assert [u.id for u in users] == [42, 43]
assert str(users[0].uuid) == LEGACY_UUID
assert requests[1].url.params['cursor'] == '42'
async def test_renewal_preserves_local_identity_and_sends_only_numeric_identifier():
def handler(request):
if request.method == 'PATCH':
assert json.loads(request.content) == {'id': 42, 'expireAt': '2027-01-01T00:00:00Z'}
else:
assert request.url.path == '/api/users/42'
return httpx.Response(200, json={'response': user_payload()})
api, http = client_for(handler)
async with http:
user = await api.update_user({'uuid': LEGACY_UUID, 'username': 'user42',
'expireAt': '2027-01-01T00:00:00Z'})
assert str(user.uuid) == LEGACY_UUID
assert user.id == 42
async def test_history_and_hwid_use_ids_and_preserve_local_foreign_keys():
timestamp = '2026-10-19T00:00:00Z'
def handler(request):
path = request.url.path
if path.endswith('subscription-request-history'):
assert '/users/42/' in path
data = {'total': 1, 'records': [{'id': 1, 'userId': 42, 'requestAt': timestamp}]}
elif request.method == 'POST':
body = json.loads(request.content)
assert body['userId'] == 42 and 'userUuid' not in body
data = {'total': 0, 'devices': []}
else:
assert path == '/api/hwid/devices/42'
data = {'total': 1, 'devices': [{'hwid': 'test-device-001', 'userId': 42,
'createdAt': timestamp, 'updatedAt': timestamp}]}
return httpx.Response(200, json={'response': data})
api, http = client_for(handler)
async with http:
history = await api.get_user_subscription_history(LEGACY_UUID)
devices = await api.get_user_hwid_devices(LEGACY_UUID)
await api.delete_user_hwid_device(user_uuid=LEGACY_UUID, hwid='test-device-001')
await api.delete_all_user_hwid_devices(LEGACY_UUID)
assert str(history.records[0].user_uuid) == LEGACY_UUID
assert str(devices.devices[0].user_uuid) == LEGACY_UUID
async def test_uuid_resolution_uses_numeric_id():
def handler(request):
assert json.loads(request.content) == {'id': 42}
return httpx.Response(200, json={'response': {'id': 42, 'username': 'user42', 'shortUuid': 'short42'}})
api, http = client_for(handler)
async with http:
resolved = await api.resolve_user(LEGACY_UUID)
assert str(resolved.uuid) == LEGACY_UUID
async def test_new_user_alias_survives_restart_before_local_cache_sync():
def handler(request):
if request.url.path.endswith('/stream'):
data = {'users': [user_payload(99)], 'hasMore': False, 'nextCursor': None}
else:
assert request.url.path in ('/api/users', '/api/users/99')
data = user_payload(99)
return httpx.Response(200, json={'response': data})
api, http = client_for(handler)
async with http:
first = await api.create_user({'username': 'user99'})
restarted, http2 = client_for(handler)
async with http2:
restored = await restarted.get_user_by_uuid(str(first.uuid))
assert restored.uuid == first.uuid
assert restored.id == 99
async def test_missing_identity_never_updates_an_unrelated_user():
def handler(request):
assert request.method == 'GET'
return httpx.Response(200, json={'response': {'users': [], 'hasMore': False, 'nextCursor': None}})
api, http = client_for(handler)
async with http:
with pytest.raises(RemnawaveApiError) as error:
await api.update_user({'uuid': 'b2222222-2222-4222-8222-222222222222', 'status': 'DISABLED'})
assert error.value.status_code == 404
async def test_stream_repeated_cursor_fails_instead_of_looping():
api, http = client_for(lambda request: httpx.Response(200, json={'response': {
'users': [], 'hasMore': True, 'nextCursor': '42'}}))
async with http:
with pytest.raises(RemnawaveApiError, match='cursor'):
await api.get_users_by_telegram_id(777)
async def test_v3_client_full_sync_preserves_existing_database_identity(session_factory):
from app.config import Settings
from app.services.sync_service import SyncService
from sqlalchemy import select
payload = user_payload()
def handler(request):
if request.url.path.endswith('/stream'):
result = {'users': [payload], 'hasMore': False, 'nextCursor': None}
else:
result = {'users': [payload], 'total': 1}
return httpx.Response(200, json={'response': result})
api, http = client_for(handler)
service = SyncService(settings=Settings.model_construct(bot_admin_ids_raw='', sync_subscription_history=False),
session_factory=session_factory, remnawave_client=api)
async with http:
initial = await service.sync_users_for_telegram(telegram_id=777, username='owner',
first_name='Owner', last_name=None, language_code='ru')
original_id = initial[0].record.id
assert initial[0].record.rw_uuid == LEGACY_UUID
assert await service.sync_all_users(batch_size=100) == 1
refreshed = await service.refresh_cached_users_for_telegram(telegram_id=777)
assert refreshed[0].record.id == original_id
assert refreshed[0].record.owner_telegram_user_id == initial[0].record.owner_telegram_user_id
async with session_factory() as session:
rows = list((await session.scalars(select(RemnawaveUser))).all())
assert len(rows) == 1
assert rows[0].rw_id == 42
assert rows[0].rw_uuid == LEGACY_UUID
async def test_renewal_rejects_rename_before_patch():
def handler(request):
assert request.method == 'GET'
return httpx.Response(200, json={'response': user_payload()})
api, http = client_for(handler)
async with http:
with pytest.raises(ValueError, match='rename'):
await api.update_user({'uuid': LEGACY_UUID, 'username': 'other-user'})
async def test_startup_preserves_deleted_cache_but_never_routes_it_to_numeric_id():
deleted_uuid='b2222222-2222-4222-8222-222222222222'
def handler(request):
assert request.method=='GET' and request.url.path=='/api/users/stream'
return httpx.Response(200, json={'response': {'users':[user_payload()], 'hasMore':False,'nextCursor':None}})
api,http=client_for(handler)
async with http:
assert await api.validate_cached_identities([(42,LEGACY_UUID),(7,deleted_uuid)])==1
with pytest.raises(RemnawaveApiError) as error:
await api.update_user({'uuid':deleted_uuid,'status':'DISABLED'})
assert error.value.status_code==404
async def test_startup_fails_when_deleted_cached_id_is_present_in_remote_stream():
def handler(request):
return httpx.Response(200, json={'response': {'users':[user_payload(7)],'hasMore':False,'nextCursor':None}})
api,http=client_for(handler)
async with http:
with pytest.raises(ValueError, match='cache'):
await api.validate_cached_identities([(7,'b2222222-2222-4222-8222-222222222222')])