From 372781b95688777d526754ee1fb2ecc8f737e3d2 Mon Sep 17 00:00:00 2001 From: Y0zhyck Date: Wed, 28 Oct 2026 13:01:59 +0300 Subject: [PATCH] rewrite v3 --- .env.example | 204 ++----- .gitignore | 16 +- README.md | 821 +---------------------------- app/config.py | 13 +- app/main.py | 19 + app/services/bot_config_service.py | 12 +- app/services/payment_service.py | 2 +- app/services/remnawave_client.py | 143 ++++- app/services/remnawave_identity.py | 72 +++ tests/test_bot_config_service.py | 7 - tests/test_config.py | 8 +- tests/test_formatters.py | 13 - tests/test_payment_service.py | 110 ---- tests/test_remnawave_identity.py | 49 ++ tests/test_remnawave_v3.py | 196 +++++++ 15 files changed, 527 insertions(+), 1158 deletions(-) create mode 100644 app/services/remnawave_identity.py create mode 100644 tests/test_remnawave_identity.py create mode 100644 tests/test_remnawave_v3.py diff --git a/.env.example b/.env.example index 07d2794..8d13033 100644 --- a/.env.example +++ b/.env.example @@ -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/// -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/// -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-- -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 diff --git a/.gitignore b/.gitignore index d00c96c..5b4dc52 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index ac06be8..2203dc2 100644 --- a/README.md +++ b/README.md @@ -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-- -``` - -Пример: - -```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 ` — найти и синхронизировать пользователя -- `/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/?start=ref_` -- сам переход по ссылке скидку не активирует -- чтобы получить скидку, пользователь должен вручную ввести код до оплаты -- размер скидки задаётся в `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 < 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 в настройках, бренд/префикс по умолчанию и примерные цены. Бизнес-логика и обязательная проверка карты не менялись. diff --git a/app/config.py b/app/config.py index 0e4e88e..11eac64 100644 --- a/app/config.py +++ b/app/config.py @@ -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: diff --git a/app/main.py b/app/main.py index aa14449..41bb43c 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/app/services/bot_config_service.py b/app/services/bot_config_service.py index f5f2240..a51831b 100644 --- a/app/services/bot_config_service.py +++ b/app/services/bot_config_service.py @@ -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///`.", - 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="Итоговый логин строится как `--`.", - placeholder="Oreol", + placeholder="Example", ), "payment_user_tag": BotConfigFieldSpec( key="payment_user_tag", diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 2880da6..d66a4a9 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -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] diff --git a/app/services/remnawave_client.py b/app/services/remnawave_client.py index 9ce1050..e7a2deb 100644 --- a/app/services/remnawave_client.py +++ b/app/services/remnawave_client.py @@ -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: diff --git a/app/services/remnawave_identity.py b/app/services/remnawave_identity.py new file mode 100644 index 0000000..f973a81 --- /dev/null +++ b/app/services/remnawave_identity.py @@ -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 diff --git a/tests/test_bot_config_service.py b/tests/test_bot_config_service.py index 58a20f4..8d5b820 100644 --- a/tests/test_bot_config_service.py +++ b/tests/test_bot_config_service.py @@ -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), diff --git a/tests/test_config.py b/tests/test_config.py index 997f806..3f81381 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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 diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 6940dae..8065512 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -75,19 +75,6 @@ def test_format_issued_access_contains_subscription_data() -> None: assert "/me" 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 "<важно>" in text - - def test_format_issued_access_for_renewal_mentions_extension() -> None: text = format_issued_access( subscription_url="https://example.com/sub", diff --git a/tests/test_payment_service.py b/tests/test_payment_service.py index a44982f..c87cad6 100644 --- a/tests/test_payment_service.py +++ b/tests/test_payment_service.py @@ -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", diff --git a/tests/test_remnawave_identity.py b/tests/test_remnawave_identity.py new file mode 100644 index 0000000..e8c1166 --- /dev/null +++ b/tests/test_remnawave_identity.py @@ -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')]) diff --git a/tests/test_remnawave_v3.py b/tests/test_remnawave_v3.py new file mode 100644 index 0000000..7661f17 --- /dev/null +++ b/tests/test_remnawave_v3.py @@ -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')])