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 @@
# ========================= # Copy to .env, then set YOUR credentials. Do not share .env.
# Telegram bot BOT_TOKEN=
# ========================= BOT_ADMIN_IDS=
# Токен бота от BotFather
BOT_TOKEN=1234567890:REPLACE_WITH_BOT_TOKEN
# Telegram ID администраторов через запятую
# Пример: 123456789,987654321
BOT_ADMIN_IDS=123456789
# Telegram ID модераторов через запятую
# Модераторы могут отвечать на тикеты и подтверждать оплаты
BOT_MODERATOR_IDS= BOT_MODERATOR_IDS=
BOT_BRAND_NAME="Example VPN"
# Прокси для доступа к Telegram Bot API, если хост не видит api.telegram.org напрямую BOT_PUBLIC_USERNAME=
# Подходят обычные URL: http://... или socks5://... 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= 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 REMNAWAVE_BASE_URL=https://panel.example.com
REMNAWAVE_API_VERSION=3
# API token Remnawave REMNAWAVE_API_TOKEN=
REMNAWAVE_API_TOKEN=replace-with-remnawave-api-token
# Дополнительный X-Api-Key, если API закрыт через Caddy
REMNAWAVE_CADDY_API_KEY= REMNAWAVE_CADDY_API_KEY=
# HTTP timeout в секундах для запросов к Remnawave
REMNAWAVE_TIMEOUT_SECONDS=20 REMNAWAVE_TIMEOUT_SECONDS=20
# =========================
# MariaDB
# =========================
# Хост MariaDB
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
# Порт MariaDB
DB_PORT=3306 DB_PORT=3306
# Имя базы данных
DB_NAME=telegabot DB_NAME=telegabot
# Пользователь MariaDB
DB_USER=telegabot DB_USER=telegabot
DB_PASSWORD=
# Пароль MariaDB CREATE_DATABASE_ON_START=false
DB_PASSWORD=change_me CREATE_TABLES_ON_START=true
# Root-пароль MariaDB для docker-compose
# Нужен контейнеру db при первом запуске
DB_ROOT_PASSWORD=change_root_me
# Логировать SQLAlchemy SQL-запросы в консоль
# true = логировать, false = не логировать
DB_ECHO=false DB_ECHO=false
# Попытаться создать базу при старте, если её ещё нет # Example price in rubles; configure your own products and payment details.
CREATE_DATABASE_ON_START=true PAYMENT_PLANS=30:100
PAYMENT_TRANSFER_TEXT="Укажите собственные реквизиты для оплаты."
# Создавать таблицы 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
PAYMENT_REVIEW_CHAT_ID= PAYMENT_REVIEW_CHAT_ID=
# Альтернативный способ указать thread/topic id review-чата вручную
# Если > 0, имеет приоритет над PAYMENT_REVIEW_LINK
PAYMENT_REVIEW_THREAD_ID=0 PAYMENT_REVIEW_THREAD_ID=0
PAYMENT_INTERNAL_SQUAD_UUIDS=
# Процент скидки по реферальному коду PAYMENT_VPN_SQUAD_UUIDS=
REFERRAL_DISCOUNT_PERCENT=5 PAYMENT_WHITE_SQUAD_UUIDS=
# Бонус рефереру за подтверждённую оплату приглашённого пользователя
# Начисляется в днях и продлевает доступ
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_EXTERNAL_SQUAD_UUID=
PAYMENT_USERNAME_PREFIX=Example
# Текст для пользователя, если оплата отклонена или доступ не выдался
PAYMENT_SUPPORT_TEXT=Если оплата прошла, но доступ не выдался, напишите в поддержку.
# Префикс логина Remnawave
# Итоговый логин будет вида Oreol-<telegram_id>-<username>
PAYMENT_USERNAME_PREFIX=Oreol
# Tag для пользователей, создаваемых ботом в Remnawave
PAYMENT_USER_TAG=BOT 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
.env.local .env.*
!.env.example
app/legacy_user_ids.json
.venv/ .venv/
.analysis-venv/
.verify-venv/
.ticket-verify-venv/
__pycache__/ __pycache__/
.pytest_cache/ .pytest_cache/
.mypy_cache/ *.log
.ruff_cache/
*.py[cod]
*.pyo
*.pyd
*.egg-info/
build/
dist/
+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` Зависимости: Python 3.12+ (Dockerfile использует 3.13), MariaDB, доступ к API Remnawave, собственный Telegram-бот и его токен. Полный список библиотек находится в pyproject.toml. Для нового окружения версии зависимостей разрешаются из указанных диапазонов; серверный образ и lock-файл не приложены.
- Подтягивает аккаунты Remnawave по `telegramId`
- Хранит локальный кэш пользователей и истории подписок в MariaDB
- Поддерживает привязку существующего аккаунта Remnawave по `short_uuid`
- Показывает профиль, статус подписки, срок и трафик
- Работает с реферальными кодами и скидкой перед оплатой
- Поддерживает ручную оплату переводом с отправкой чека в бот
- Отправляет чек в отдельный Telegram-канал/топик на проверку
- Даёт админу или модератору подтвердить или отклонить оплату
- После подтверждения создаёт или продлевает доступ в Remnawave
- Поддерживает тикеты в отдельный канал и ответы только от админа/модератора
- Показывает админам отдельную Telegram-панель с метриками и быстрыми действиями
## Текущий сценарий оплаты 1. Создайте отдельную БД MariaDB и пользователя с правами на неё. Укажите собственные DB_*.
2. Скопируйте .env.example в .env и заполните токены, адрес панели, Telegram ID администратора и настройки чатов/оплаты/сквадов. Не отправляйте заполненный .env другим людям.
Сейчас в проекте не используется Telegram Stars. Оплата работает вручную: 3. Подготовьте карту ID, описанную ниже. Без неё режим v3 не запустится.
4. В терминале из каталога проекта:
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:
```powershell ```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` Для разработки: `python -m pip install ".[dev]"` и `python -m pytest`. Исходные тесты включены; при изменении примерных настроек сверяйте ожидаемые значения тестов.
- `BOT_ADMIN_IDS`
- `REMNAWAVE_BASE_URL`
- `REMNAWAVE_API_TOKEN`
- `DB_NAME`
- `DB_USER`
- `DB_PASSWORD`
- `DB_ROOT_PASSWORD`
- `PAYMENT_INTERNAL_SQUAD_UUIDS`
Обычно ещё сразу настраивают: ## Обязательная карта ID для v3
- `BOT_PUBLIC_USERNAME` `app/legacy_user_ids.json` специально НЕ включён: он содержал реальные данные пользователей. В текущем коде v3 требует непустой JSON-массив соответствий полей `id` (числовой ID пользователя панели) и `uuid` (прежний локальный UUID того же пользователя). Карта должна принадлежать вашей панели и точно совпадать с существующим кэшем бота. Код проверяет соответствия и исторические записи перед началом polling. Не копируйте чужую карту, не подставляйте случайные UUID и не удаляйте защиту ради запуска.
- `BOT_SUPPORT_URL`
- `BOT_SUPPORT_TICKET_LINK` или `BOT_SUPPORT_TICKET_CHAT_ID`
- `PAYMENT_TRANSFER_TEXT`
- `PAYMENT_REVIEW_LINK` или `PAYMENT_REVIEW_CHAT_ID`
#### Важный момент по базе данных в 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` Исходные файлы сохранены; в экспортной копии заменены личная ссылка Telegram в настройках, бренд/префикс по умолчанию и примерные цены. Бизнес-логика и обязательная проверка карты не менялись.
- `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`
- открытие раздела подписки через кнопку в панели
- ввод реферального кода
- отправку чека
- подтверждение оплаты модератором
- выдачу ссылки пользователю
- поддержку через тикет
+7 -6
View File
@@ -48,7 +48,7 @@ class Settings(BaseSettings):
bot_admin_ids_raw: str = Field(default="", validation_alias="BOT_ADMIN_IDS") bot_admin_ids_raw: str = Field(default="", validation_alias="BOT_ADMIN_IDS")
bot_moderator_ids_raw: str = Field(default="", validation_alias="BOT_MODERATOR_IDS") bot_moderator_ids_raw: str = Field(default="", validation_alias="BOT_MODERATOR_IDS")
telegram_proxy_url: str = Field(default="", validation_alias="TELEGRAM_PROXY_URL") 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_public_username: str = Field(default="", validation_alias="BOT_PUBLIC_USERNAME")
bot_support_url: str = Field(default="", validation_alias="BOT_SUPPORT_URL") bot_support_url: str = Field(default="", validation_alias="BOT_SUPPORT_URL")
bot_terms_url: str = Field(default="", validation_alias="BOT_TERMS_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") bot_start_image_path: str = Field(default="", validation_alias="BOT_START_IMAGE_PATH")
remnawave_base_url: str = Field(validation_alias="REMNAWAVE_BASE_URL") 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_api_token: str = Field(default="", validation_alias="REMNAWAVE_API_TOKEN")
remnawave_caddy_api_key: str = Field(default="", validation_alias="REMNAWAVE_CADDY_API_KEY") remnawave_caddy_api_key: str = Field(default="", validation_alias="REMNAWAVE_CADDY_API_KEY")
remnawave_timeout_seconds: float = Field( remnawave_timeout_seconds: float = Field(
@@ -97,7 +98,7 @@ class Settings(BaseSettings):
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL") log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
payment_plans_raw: str = Field( payment_plans_raw: str = Field(
default="30:250,180:600,365:1000", default="30:100",
validation_alias="PAYMENT_PLANS", validation_alias="PAYMENT_PLANS",
) )
payment_product_plans_raw: str = Field( payment_product_plans_raw: str = Field(
@@ -122,7 +123,7 @@ class Settings(BaseSettings):
validation_alias="PAYMENT_REVIEW_THREAD_ID", validation_alias="PAYMENT_REVIEW_THREAD_ID",
) )
broadcast_results_link: str = Field( broadcast_results_link: str = Field(
default="https://t.me/c/3646494169/67/68", default="",
validation_alias="BROADCAST_RESULTS_LINK", validation_alias="BROADCAST_RESULTS_LINK",
) )
broadcast_results_chat_id_raw: str = Field( 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_code: str = Field(default="vpn_30d", validation_alias="PAYMENT_PLAN_CODE")
payment_plan_title: str = Field( payment_plan_title: str = Field(
default="OREOL VPN на 30 дней", default="Example VPN на 30 дней",
validation_alias="PAYMENT_PLAN_TITLE", validation_alias="PAYMENT_PLAN_TITLE",
) )
payment_plan_description: str = Field( payment_plan_description: str = Field(
@@ -184,7 +185,7 @@ class Settings(BaseSettings):
validation_alias="PAYMENT_SUPPORT_TEXT", validation_alias="PAYMENT_SUPPORT_TEXT",
) )
payment_username_prefix: str = Field( payment_username_prefix: str = Field(
default="Oreol", default="Example",
validation_alias="PAYMENT_USERNAME_PREFIX", validation_alias="PAYMENT_USERNAME_PREFIX",
) )
payment_user_tag: str = Field(default="BOT", validation_alias="PAYMENT_USER_TAG") payment_user_tag: str = Field(default="BOT", validation_alias="PAYMENT_USER_TAG")
@@ -323,7 +324,7 @@ class Settings(BaseSettings):
normalized = "".join( normalized = "".join(
ch for ch in self.payment_username_prefix.strip() if ch.isalnum() or ch in "_-" 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 @cached_property
def payment_user_tag_normalized(self) -> str | None: def payment_user_tag_normalized(self) -> str | None:
+19
View File
@@ -2,6 +2,8 @@ import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
from sqlalchemy import select
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession 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.bot.handlers.main import build_router
from app.config import Settings 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.db.session import create_engine_and_session_factory, ensure_database_exists, init_db
from app.services.bot_config_service import BotConfigService from app.services.bot_config_service import BotConfigService
from app.services.notification_service import SubscriptionNotificationService from app.services.notification_service import SubscriptionNotificationService
@@ -55,12 +59,27 @@ async def main() -> None:
if settings.create_tables_on_start: if settings.create_tables_on_start:
await init_db(engine) 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( remnawave_client = RemnawaveApiClient(
base_url=settings.remnawave_base_url, base_url=settings.remnawave_base_url,
api_token=settings.remnawave_api_token, api_token=settings.remnawave_api_token,
caddy_api_key=settings.remnawave_caddy_api_key, caddy_api_key=settings.remnawave_caddy_api_key,
timeout_seconds=settings.remnawave_timeout_seconds, 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( sync_service = SyncService(
settings=settings, settings=settings,
session_factory=session_factory, session_factory=session_factory,
+6 -6
View File
@@ -71,7 +71,7 @@ class BotConfigSnapshot:
normalized = "".join( normalized = "".join(
ch for ch in self.payment_username_prefix.strip() if ch.isalnum() or ch in "_-" 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 @property
def payment_user_tag_normalized(self) -> str | None: def payment_user_tag_normalized(self) -> str | None:
@@ -323,7 +323,7 @@ class BotConfigService(StaticBotConfigService):
section="brand", section="brand",
prompt="Введите новое название бренда.", prompt="Введите новое название бренда.",
description="Используется в главной панели и пользовательских сообщениях.", description="Используется в главной панели и пользовательских сообщениях.",
placeholder="OREOL VPN", placeholder="Example VPN",
), ),
"bot_public_username": BotConfigFieldSpec( "bot_public_username": BotConfigFieldSpec(
key="bot_public_username", key="bot_public_username",
@@ -331,7 +331,7 @@ class BotConfigService(StaticBotConfigService):
section="brand", section="brand",
prompt="Введите username бота без @. Для очистки отправьте `-`.", prompt="Введите username бота без @. Для очистки отправьте `-`.",
description="Нужен для генерации реферальных ссылок.", description="Нужен для генерации реферальных ссылок.",
placeholder="oreol_vpn_bot", placeholder="example_vpn_bot",
), ),
"bot_start_image_enabled": BotConfigFieldSpec( "bot_start_image_enabled": BotConfigFieldSpec(
key="bot_start_image_enabled", key="bot_start_image_enabled",
@@ -450,7 +450,7 @@ class BotConfigService(StaticBotConfigService):
section="links", section="links",
prompt="Введите приватную ссылку на чат/топик, куда будут приходить ответы опросов. Для очистки отправьте `-`.", prompt="Введите приватную ссылку на чат/топик, куда будут приходить ответы опросов. Для очистки отправьте `-`.",
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.", 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( "payment_transfer_text": BotConfigFieldSpec(
key="payment_transfer_text", key="payment_transfer_text",
@@ -458,7 +458,7 @@ class BotConfigService(StaticBotConfigService):
section="texts", section="texts",
prompt="Введите текст с реквизитами или инструкцией перевода.", prompt="Введите текст с реквизитами или инструкцией перевода.",
description="Показывается пользователю после выбора тарифа.", description="Показывается пользователю после выбора тарифа.",
placeholder="Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN", placeholder="Карта 0000 0000 0000 0000; банк Пример; получатель Example VPN",
), ),
"payment_support_text": BotConfigFieldSpec( "payment_support_text": BotConfigFieldSpec(
key="payment_support_text", key="payment_support_text",
@@ -514,7 +514,7 @@ class BotConfigService(StaticBotConfigService):
section="provisioning", section="provisioning",
prompt="Введите префикс логина для новых пользователей Remnawave.", prompt="Введите префикс логина для новых пользователей Remnawave.",
description="Итоговый логин строится как `<prefix>-<telegram_id>-<name>`.", description="Итоговый логин строится как `<prefix>-<telegram_id>-<name>`.",
placeholder="Oreol", placeholder="Example",
), ),
"payment_user_tag": BotConfigFieldSpec( "payment_user_tag": BotConfigFieldSpec(
key="payment_user_tag", key="payment_user_tag",
+1 -1
View File
@@ -1285,7 +1285,7 @@ class PaymentService:
base_name = username or first_name or "user" base_name = username or first_name or "user"
normalized_name = self._normalize_provision_name(base_name) 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] unique_suffix = order_uuid.replace("-", "")[:6]
provision_username = f"{prefix}-{telegram_id}-{unique_suffix}-{normalized_name}" provision_username = f"{prefix}-{telegram_id}-{unique_suffix}-{normalized_name}"
return provision_username[:36] return provision_username[:36]
+117 -26
View File
@@ -1,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
import re import re
from uuid import UUID
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any from typing import Any
import httpx 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 from app.schemas.remnawave import HwidDevicesResponse, PaginatedUsers, RemnawaveUser, ResolvedUser, SubscriptionRequestHistory
@@ -35,7 +37,20 @@ class RemnawaveApiClient:
caddy_api_key: str = "", caddy_api_key: str = "",
timeout_seconds: float = 20.0, timeout_seconds: float = 20.0,
client: httpx.AsyncClient | None = None, client: httpx.AsyncClient | None = None,
api_version: int = 2,
legacy_user_ids: dict[int, str] | None = 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._managed_client = client is None
self._client = client or httpx.AsyncClient( self._client = client or httpx.AsyncClient(
base_url=self.normalize_base_url(base_url), base_url=self.normalize_base_url(base_url),
@@ -83,11 +98,24 @@ class RemnawaveApiClient:
if self._managed_client: if self._managed_client:
await self._client.aclose() 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]: 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}") payload = await self._request_json("GET", f"users/by-telegram-id/{telegram_id}")
return [RemnawaveUser.model_validate(item) for item in payload] return [RemnawaveUser.model_validate(item) for item in payload]
async def get_user_by_uuid(self, user_uuid: str) -> RemnawaveUser: 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}") payload = await self._request_json("GET", f"users/{user_uuid}")
return RemnawaveUser.model_validate(payload) return RemnawaveUser.model_validate(payload)
@@ -100,10 +128,24 @@ class RemnawaveApiClient:
return RemnawaveUser.model_validate(payload) return RemnawaveUser.model_validate(payload)
async def create_user(self, body: dict[str, Any]) -> RemnawaveUser: 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) payload = await self._request_json("POST", "users", json=body)
return RemnawaveUser.model_validate(payload) return RemnawaveUser.model_validate(payload)
async def update_user(self, body: dict[str, Any]) -> RemnawaveUser: 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) payload = await self._request_json("PATCH", "users", json=body)
return RemnawaveUser.model_validate(payload) return RemnawaveUser.model_validate(payload)
@@ -116,6 +158,8 @@ class RemnawaveApiClient:
return PaginatedUsers.model_validate(payload) return PaginatedUsers.model_validate(payload)
async def get_user_subscription_history(self, user_uuid: str) -> SubscriptionRequestHistory: 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( payload = await self._request_json(
"GET", "GET",
f"users/{user_uuid}/subscription-request-history", f"users/{user_uuid}/subscription-request-history",
@@ -126,7 +170,8 @@ class RemnawaveApiClient:
body: dict[str, Any] body: dict[str, Any]
if UUID_RE.match(identifier): 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(): elif identifier.isdigit():
body = {"id": int(identifier)} body = {"id": int(identifier)}
else: else:
@@ -146,22 +191,28 @@ class RemnawaveApiClient:
return ResolvedUser.model_validate(payload) return ResolvedUser.model_validate(payload)
async def get_user_hwid_devices(self, user_uuid: str) -> HwidDevicesResponse: 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}") payload = await self._request_json("GET", f"hwid/devices/{user_uuid}")
return HwidDevicesResponse.model_validate(payload) return HwidDevicesResponse.model_validate(payload)
async def delete_user_hwid_device(self, *, user_uuid: str, hwid: str) -> HwidDevicesResponse: 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( payload = await self._request_json(
"POST", "POST",
"hwid/devices/delete", "hwid/devices/delete",
json={"userUuid": user_uuid, "hwid": hwid}, json={**user_field, "hwid": hwid},
) )
return HwidDevicesResponse.model_validate(payload) return HwidDevicesResponse.model_validate(payload)
async def delete_all_user_hwid_devices(self, user_uuid: str) -> HwidDevicesResponse: 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( payload = await self._request_json(
"POST", "POST",
"hwid/devices/delete-all", "hwid/devices/delete-all",
json={"userUuid": user_uuid}, json=user_field,
) )
return HwidDevicesResponse.model_validate(payload) return HwidDevicesResponse.model_validate(payload)
@@ -173,28 +224,12 @@ class RemnawaveApiClient:
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None, json: dict[str, Any] | None = None,
) -> Any: ) -> Any:
try: response = await self._client.request(
response = await self._client.request( method=method,
method=method, url=path.lstrip("/"),
url=path.lstrip("/"), params=params,
params=params, json=json,
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
if response.is_error: if response.is_error:
raise self._build_error(response) raise self._build_error(response)
@@ -204,7 +239,63 @@ class RemnawaveApiClient:
except ValueError: except ValueError:
return response.text 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: def _build_error(self, response: httpx.Response) -> RemnawaveApiError:
try: 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_plans_raw="30:250,180:600",
payment_transfer_text="Реквизиты", payment_transfer_text="Реквизиты",
payment_support_text="Напишите в поддержку", payment_support_text="Напишите в поддержку",
payment_success_text="Доступ выдан",
payment_plan_traffic_limit_gb=0, payment_plan_traffic_limit_gb=0,
payment_plan_traffic_reset_period="NO_RESET", payment_plan_traffic_reset_period="NO_RESET",
payment_internal_squad_uuids_raw="uuid-1", 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", raw_value="off",
updated_by_telegram_id=1, 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() snapshot = await service.get_snapshot()
assert snapshot.bot_brand_name == "NEW BRAND" assert snapshot.bot_brand_name == "NEW BRAND"
assert snapshot.referral_enabled is False assert snapshot.referral_enabled is False
assert snapshot.payment_success_text == "Новый текст после оплаты"
assert [(plan.days, plan.amount_rub) for plan in snapshot.payment_plans] == [ assert [(plan.days, plan.amount_rub) for plan in snapshot.payment_plans] == [
(30, 300), (30, 300),
(90, 700), (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: def test_support_ticket_link_parses_private_topic_link() -> None:
settings = Settings.model_construct( 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_chat_id_raw="",
bot_support_ticket_thread_id=0, 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 assert settings.support_ticket_message_thread_id == 2
def test_payment_review_link_parses_private_topic_link() -> None: def test_payment_review_link_parses_private_topic_link() -> None:
settings = Settings.model_construct( 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_chat_id_raw="",
payment_review_thread_id=0, 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 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 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: def test_format_issued_access_for_renewal_mentions_extension() -> None:
text = format_issued_access( text = format_issued_access(
subscription_url="https://example.com/sub", 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 {} self.users_by_telegram_id = users_by_telegram_id or {}
async def get_user_by_uuid(self, user_uuid: str): 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") raise RemnawaveApiError(status_code=404, message="not found")
async def get_user_by_username(self, username: str): 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 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: async def test_create_order_marks_existing_access_as_renewal(session_factory) -> None:
service = PaymentService( service = PaymentService(
settings=Settings.model_construct( 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) 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: async def test_approve_order_extends_existing_user_found_by_telegram_id(session_factory) -> None:
existing_remote_user = make_remote_user( existing_remote_user = make_remote_user(
user_uuid="88888888-8888-8888-8888-888888888888", 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')])