rewrite v3
This commit is contained in:
+31
-173
@@ -1,190 +1,48 @@
|
||||
# =========================
|
||||
# Telegram bot
|
||||
# =========================
|
||||
|
||||
# Токен бота от BotFather
|
||||
BOT_TOKEN=1234567890:REPLACE_WITH_BOT_TOKEN
|
||||
|
||||
# Telegram ID администраторов через запятую
|
||||
# Пример: 123456789,987654321
|
||||
BOT_ADMIN_IDS=123456789
|
||||
|
||||
# Telegram ID модераторов через запятую
|
||||
# Модераторы могут отвечать на тикеты и подтверждать оплаты
|
||||
# Copy to .env, then set YOUR credentials. Do not share .env.
|
||||
BOT_TOKEN=
|
||||
BOT_ADMIN_IDS=
|
||||
BOT_MODERATOR_IDS=
|
||||
|
||||
# Прокси для доступа к Telegram Bot API, если хост не видит api.telegram.org напрямую
|
||||
# Подходят обычные URL: http://... или socks5://...
|
||||
BOT_BRAND_NAME="Example VPN"
|
||||
BOT_PUBLIC_USERNAME=
|
||||
BOT_SUPPORT_URL=
|
||||
BOT_TERMS_URL=
|
||||
BOT_SUPPORT_TICKET_CHAT_ID=
|
||||
BOT_SUPPORT_TICKET_THREAD_ID=0
|
||||
BOT_START_IMAGE_ENABLED=false
|
||||
BOT_START_IMAGE_PATH=
|
||||
TELEGRAM_PROXY_URL=
|
||||
|
||||
# Название бренда в пользовательской панели
|
||||
BOT_BRAND_NAME=OREOL VPN
|
||||
|
||||
# Username бота без @, нужен для реферальных ссылок
|
||||
BOT_PUBLIC_USERNAME=your_bot_username
|
||||
|
||||
# Опциональная ссылка на поддержку
|
||||
BOT_SUPPORT_URL=https://t.me/your_support_account
|
||||
|
||||
# Опциональная внешняя ссылка на Terms of Use
|
||||
BOT_TERMS_URL=
|
||||
|
||||
# Ссылка на приватный чат/топик для тикетов поддержки
|
||||
# Формат: https://t.me/c/<chat>/<topic>/<message>
|
||||
BOT_SUPPORT_TICKET_LINK=https://t.me/c/1234567890/2/3
|
||||
|
||||
# Альтернативный способ указать chat id для тикетов вручную
|
||||
# Если заполнен, имеет приоритет над BOT_SUPPORT_TICKET_LINK
|
||||
BOT_SUPPORT_TICKET_CHAT_ID=
|
||||
|
||||
# Альтернативный способ указать thread/topic id для тикетов вручную
|
||||
# Если > 0, имеет приоритет над BOT_SUPPORT_TICKET_LINK
|
||||
BOT_SUPPORT_TICKET_THREAD_ID=0
|
||||
|
||||
# Включить стартовую картинку в панели
|
||||
BOT_START_IMAGE_ENABLED=true
|
||||
|
||||
# Путь до стартовой картинки
|
||||
# Можно абсолютный путь или относительный от корня проекта
|
||||
BOT_START_IMAGE_PATH=assets/main.png
|
||||
|
||||
|
||||
# =========================
|
||||
# Remnawave
|
||||
# =========================
|
||||
|
||||
# Базовый URL Remnawave без /api или с /api — клиент нормализует сам
|
||||
REMNAWAVE_BASE_URL=https://panel.example.com
|
||||
|
||||
# API token Remnawave
|
||||
REMNAWAVE_API_TOKEN=replace-with-remnawave-api-token
|
||||
|
||||
# Дополнительный X-Api-Key, если API закрыт через Caddy
|
||||
REMNAWAVE_API_VERSION=3
|
||||
REMNAWAVE_API_TOKEN=
|
||||
REMNAWAVE_CADDY_API_KEY=
|
||||
|
||||
# HTTP timeout в секундах для запросов к Remnawave
|
||||
REMNAWAVE_TIMEOUT_SECONDS=20
|
||||
|
||||
|
||||
# =========================
|
||||
# MariaDB
|
||||
# =========================
|
||||
|
||||
# Хост MariaDB
|
||||
DB_HOST=127.0.0.1
|
||||
|
||||
# Порт MariaDB
|
||||
DB_PORT=3306
|
||||
|
||||
# Имя базы данных
|
||||
DB_NAME=telegabot
|
||||
|
||||
# Пользователь MariaDB
|
||||
DB_USER=telegabot
|
||||
|
||||
# Пароль MariaDB
|
||||
DB_PASSWORD=change_me
|
||||
|
||||
# Root-пароль MariaDB для docker-compose
|
||||
# Нужен контейнеру db при первом запуске
|
||||
DB_ROOT_PASSWORD=change_root_me
|
||||
|
||||
# Логировать SQLAlchemy SQL-запросы в консоль
|
||||
# true = логировать, false = не логировать
|
||||
DB_PASSWORD=
|
||||
CREATE_DATABASE_ON_START=false
|
||||
CREATE_TABLES_ON_START=true
|
||||
DB_ECHO=false
|
||||
|
||||
# Попытаться создать базу при старте, если её ещё нет
|
||||
CREATE_DATABASE_ON_START=true
|
||||
|
||||
# Создавать таблицы ORM при старте приложения
|
||||
CREATE_TABLES_ON_START=true
|
||||
|
||||
# Сохранять историю subscription request log из Remnawave
|
||||
SYNC_SUBSCRIPTION_HISTORY=true
|
||||
|
||||
# Размер батча при /sync_all
|
||||
SYNC_BATCH_SIZE=100
|
||||
|
||||
# Уровень логов Python
|
||||
# Примеры: INFO, DEBUG, WARNING, ERROR
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
|
||||
# =========================
|
||||
# Manual payment flow
|
||||
# =========================
|
||||
|
||||
# Набор тарифов в формате дни:цена_в_рублях
|
||||
# Пример: 30:250,180:600,365:1000
|
||||
PAYMENT_PLANS=30:250,180:600,365:1000
|
||||
|
||||
# Реквизиты или инструкция для перевода
|
||||
# Этот текст бот показывает пользователю после выбора тарифа
|
||||
PAYMENT_TRANSFER_TEXT=Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN
|
||||
|
||||
# Ссылка на приватный чат/топик, куда бот отправляет чеки на модерацию
|
||||
# Формат: https://t.me/c/<chat>/<topic>/<message>
|
||||
PAYMENT_SUCCESS_TEXT=Доступ выдан. Сохраните ссылку и используйте её для подключения.
|
||||
|
||||
PAYMENT_REVIEW_LINK=https://t.me/c/1234567890/56/57
|
||||
|
||||
# Альтернативный способ указать chat id review-чата вручную
|
||||
# Если заполнен, имеет приоритет над PAYMENT_REVIEW_LINK
|
||||
# Example price in rubles; configure your own products and payment details.
|
||||
PAYMENT_PLANS=30:100
|
||||
PAYMENT_TRANSFER_TEXT="Укажите собственные реквизиты для оплаты."
|
||||
PAYMENT_REVIEW_CHAT_ID=
|
||||
|
||||
# Альтернативный способ указать thread/topic id review-чата вручную
|
||||
# Если > 0, имеет приоритет над PAYMENT_REVIEW_LINK
|
||||
PAYMENT_REVIEW_THREAD_ID=0
|
||||
|
||||
# Процент скидки по реферальному коду
|
||||
REFERRAL_DISCOUNT_PERCENT=5
|
||||
|
||||
# Бонус рефереру за подтверждённую оплату приглашённого пользователя
|
||||
# Начисляется в днях и продлевает доступ
|
||||
REFERRAL_BONUS_DAYS=7
|
||||
|
||||
|
||||
# =========================
|
||||
# Payment / Remnawave provisioning
|
||||
# =========================
|
||||
|
||||
# Резервный код тарифа по умолчанию
|
||||
# Сейчас в основном используется PAYMENT_PLANS, но поле оставлено для совместимости
|
||||
PAYMENT_PLAN_CODE=vpn_30d
|
||||
|
||||
# Резервное название базового тарифа
|
||||
PAYMENT_PLAN_TITLE=OREOL VPN на 30 дней
|
||||
|
||||
# Резервное описание базового тарифа
|
||||
PAYMENT_PLAN_DESCRIPTION=Доступ к VPN на 30 дней
|
||||
|
||||
# Резервная цена базового тарифа
|
||||
PAYMENT_PLAN_PRICE_STARS=250
|
||||
|
||||
# Резервная длительность базового тарифа
|
||||
PAYMENT_PLAN_DURATION_DAYS=30
|
||||
|
||||
# Лимит трафика в GB
|
||||
# 0 = без лимита
|
||||
PAYMENT_PLAN_TRAFFIC_LIMIT_GB=0
|
||||
|
||||
# Стратегия сброса трафика для Remnawave
|
||||
# Обычно NO_RESET
|
||||
PAYMENT_PLAN_TRAFFIC_RESET_PERIOD=NO_RESET
|
||||
|
||||
# UUID внутренних групп Remnawave, которые назначаются новому или продлеваемому доступу
|
||||
# Несколько значений через запятую
|
||||
PAYMENT_INTERNAL_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111
|
||||
|
||||
# UUID внешней группы Remnawave, если используется
|
||||
PAYMENT_INTERNAL_SQUAD_UUIDS=
|
||||
PAYMENT_VPN_SQUAD_UUIDS=
|
||||
PAYMENT_WHITE_SQUAD_UUIDS=
|
||||
PAYMENT_EXTERNAL_SQUAD_UUID=
|
||||
|
||||
# Текст для пользователя, если оплата отклонена или доступ не выдался
|
||||
PAYMENT_SUPPORT_TEXT=Если оплата прошла, но доступ не выдался, напишите в поддержку.
|
||||
|
||||
# Префикс логина Remnawave
|
||||
# Итоговый логин будет вида Oreol-<telegram_id>-<username>
|
||||
PAYMENT_USERNAME_PREFIX=Oreol
|
||||
|
||||
# Tag для пользователей, создаваемых ботом в Remnawave
|
||||
PAYMENT_USERNAME_PREFIX=Example
|
||||
PAYMENT_USER_TAG=BOT
|
||||
BROADCAST_RESULTS_LINK=
|
||||
BROADCAST_RESULTS_CHAT_ID=
|
||||
BROADCAST_RESULTS_THREAD_ID=0
|
||||
REFERRAL_DISCOUNT_PERCENT=5
|
||||
REFERRAL_BONUS_DAYS=7
|
||||
SYNC_SUBSCRIPTION_HISTORY=true
|
||||
SYNC_BATCH_SIZE=100
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
+4
-12
@@ -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
|
||||
|
||||
@@ -1,817 +1,38 @@
|
||||
# OREOL VPN Bot
|
||||
# Telegram-бот для Remnawave v3
|
||||
|
||||
Telegram-бот на `aiogram 3` с интеграцией в `Remnawave API`, локальным кэшем в `MariaDB`, пользовательской панелью, тикетами поддержки, реферальной системой и ручной оплатой с подтверждением админом.
|
||||
Исходники aiogram 3 + MariaDB с адаптацией к числовым ID Remnawave v3. Ручная оплата с проверкой администратором, поддержка, рефералы и кэш подписок.
|
||||
|
||||
Этот README написан как рабочий handoff-документ: по нему можно поднять проект, понять текущую архитектуру и продолжить разработку в новой сессии без восстановления контекста по кускам.
|
||||
## Перед запуском
|
||||
|
||||
## Что умеет бот
|
||||
Это архив исходников, а не готовая настроенная установка. Токены, реальные пользователи, рабочая карта ID, реквизиты и серверные настройки исключены. Нейтральные цены и тексты — примеры; задайте свои.
|
||||
|
||||
- Показывает стартовую панель пользователя через `/start`
|
||||
- Подтягивает аккаунты Remnawave по `telegramId`
|
||||
- Хранит локальный кэш пользователей и истории подписок в MariaDB
|
||||
- Поддерживает привязку существующего аккаунта Remnawave по `short_uuid`
|
||||
- Показывает профиль, статус подписки, срок и трафик
|
||||
- Работает с реферальными кодами и скидкой перед оплатой
|
||||
- Поддерживает ручную оплату переводом с отправкой чека в бот
|
||||
- Отправляет чек в отдельный Telegram-канал/топик на проверку
|
||||
- Даёт админу или модератору подтвердить или отклонить оплату
|
||||
- После подтверждения создаёт или продлевает доступ в Remnawave
|
||||
- Поддерживает тикеты в отдельный канал и ответы только от админа/модератора
|
||||
- Показывает админам отдельную Telegram-панель с метриками и быстрыми действиями
|
||||
Зависимости: Python 3.12+ (Dockerfile использует 3.13), MariaDB, доступ к API Remnawave, собственный Telegram-бот и его токен. Полный список библиотек находится в pyproject.toml. Для нового окружения версии зависимостей разрешаются из указанных диапазонов; серверный образ и lock-файл не приложены.
|
||||
|
||||
## Текущий сценарий оплаты
|
||||
|
||||
Сейчас в проекте не используется Telegram Stars. Оплата работает вручную:
|
||||
|
||||
1. Пользователь нажимает `Купить подписку`
|
||||
2. Бот показывает список тарифов из `PAYMENT_PLANS`
|
||||
3. Если у пользователя применён реферальный код, скидка учитывается до выбора тарифа
|
||||
4. После выбора тарифа бот показывает реквизиты из `PAYMENT_TRANSFER_TEXT`
|
||||
5. Пользователь отправляет чек следующим сообщением в бота
|
||||
6. Бот пересылает чек в review-чат оплаты
|
||||
7. Админ или модератор нажимает `Подтвердить` или `Отклонить`
|
||||
8. При подтверждении бот создаёт или продлевает доступ в Remnawave и отправляет пользователю подписку
|
||||
|
||||
### Тарифы
|
||||
|
||||
Тарифы задаются одной переменной:
|
||||
|
||||
```env
|
||||
PAYMENT_PLANS=30:250,180:600,365:1000
|
||||
```
|
||||
|
||||
Формат:
|
||||
|
||||
- `дни:цена_в_рублях`
|
||||
- элементы разделяются запятой
|
||||
|
||||
Пример выше означает:
|
||||
|
||||
- 30 дней = 250 ₽
|
||||
- 180 дней = 600 ₽
|
||||
- 365 дней = 1000 ₽
|
||||
|
||||
### Логин в Remnawave
|
||||
|
||||
Логин создаётся в формате:
|
||||
|
||||
```text
|
||||
Oreol-<telegram_id>-<username>
|
||||
```
|
||||
|
||||
Пример:
|
||||
|
||||
```text
|
||||
Oreol-591220249-slkes
|
||||
```
|
||||
|
||||
Если username отсутствует, используется `first_name`, а если и его нет, то `user`.
|
||||
|
||||
Важно:
|
||||
|
||||
- логин обрезается до 36 символов, чтобы соответствовать ограничениям Remnawave
|
||||
- повторная покупка у того же пользователя не должна создавать новый логин, а должна продлевать уже существующий доступ
|
||||
|
||||
## Важное замечание по базе
|
||||
|
||||
Если таблица `payment_orders` уже была создана старой версией проекта, в ней мог остаться `UNIQUE` на `provision_username`.
|
||||
|
||||
Для текущей логики это неверно, потому что один и тот же логин может использоваться в нескольких заказах одного пользователя при продлении.
|
||||
|
||||
Правильное состояние:
|
||||
|
||||
- в ORM `provision_username` обычный индекс без `UNIQUE`
|
||||
- в `sql/schema.sql` тоже без `UNIQUE`
|
||||
|
||||
Если таблица уже существует в MariaDB, может потребоваться вручную убрать старое уникальное ограничение.
|
||||
|
||||
## Архитектура
|
||||
|
||||
### Основные слои
|
||||
|
||||
- `app/main.py`
|
||||
- инициализация приложения
|
||||
- подключение к БД
|
||||
- создание клиента Remnawave
|
||||
- создание `SyncService`, `SupportTicketService`, `PaymentService`
|
||||
- запуск aiogram
|
||||
|
||||
- `app/config.py`
|
||||
- все настройки из `.env`
|
||||
- парсинг ID админов и модераторов
|
||||
- парсинг ссылок Telegram вида `https://t.me/c/.../.../...`
|
||||
- парсинг тарифов оплаты
|
||||
|
||||
- `app/services/remnawave_client.py`
|
||||
- прямой HTTP-клиент к Remnawave API через `httpx`
|
||||
- `GET /users/by-telegram-id/{telegramId}`
|
||||
- `GET /users/{uuid}`
|
||||
- `GET /users/by-short-uuid/{shortUuid}`
|
||||
- `GET /users/by-username/{username}`
|
||||
- `POST /users`
|
||||
- `PATCH /users`
|
||||
- `POST /users/resolve`
|
||||
- `GET /users`
|
||||
- `GET /users/{uuid}/subscription-request-history`
|
||||
|
||||
- `app/services/sync_service.py`
|
||||
- регистрация Telegram-пользователей
|
||||
- синхронизация пользователей из Remnawave в MariaDB
|
||||
- привязка аккаунтов по `short_uuid`
|
||||
- реферальные коды и приглашения
|
||||
|
||||
- `app/services/payment_service.py`
|
||||
- список доступных тарифов
|
||||
- создание ручного заказа
|
||||
- расчёт скидки по реферальному коду
|
||||
- хранение статусов заказа
|
||||
- создание нового доступа в Remnawave
|
||||
- продление существующего доступа в Remnawave
|
||||
|
||||
- `app/services/support_ticket_service.py`
|
||||
- создание тикетов
|
||||
- поиск тикета по support message ID
|
||||
- отметка, что тикет обработан
|
||||
|
||||
- `app/bot/handlers/main.py`
|
||||
- все команды и callback-и
|
||||
- стартовая панель
|
||||
- меню оплаты
|
||||
- FSM для тикетов, рефкодов и чека оплаты
|
||||
- приём ответа модератора на тикет
|
||||
- приём кнопок `Подтвердить / Отклонить` в review-канале оплаты
|
||||
|
||||
- `app/bot/ui/panel.py`
|
||||
- рендер панели пользователя
|
||||
- инлайн-кнопки разделов
|
||||
- тексты профиля, подписки, рефералки, правил и поддержки
|
||||
|
||||
- `app/utils/formatters.py`
|
||||
- форматирование дат
|
||||
- форматирование трафика
|
||||
- форматирование карточек доступа
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```text
|
||||
telegabot/
|
||||
├── app/
|
||||
│ ├── bot/
|
||||
│ │ ├── handlers/
|
||||
│ │ │ └── main.py
|
||||
│ │ └── ui/
|
||||
│ │ └── panel.py
|
||||
│ ├── db/
|
||||
│ │ ├── base.py
|
||||
│ │ ├── models.py
|
||||
│ │ └── session.py
|
||||
│ ├── schemas/
|
||||
│ │ └── remnawave.py
|
||||
│ ├── services/
|
||||
│ │ ├── payment_service.py
|
||||
│ │ ├── remnawave_client.py
|
||||
│ │ ├── support_ticket_service.py
|
||||
│ │ └── sync_service.py
|
||||
│ ├── utils/
|
||||
│ │ └── formatters.py
|
||||
│ ├── config.py
|
||||
│ └── main.py
|
||||
├── assets/
|
||||
│ └── main.png
|
||||
├── sql/
|
||||
│ └── schema.sql
|
||||
├── tests/
|
||||
│ ├── test_config.py
|
||||
│ ├── test_formatters.py
|
||||
│ ├── test_panel_ui.py
|
||||
│ └── test_remnawave_client.py
|
||||
├── .env.example
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── main.py
|
||||
├── pyproject.toml
|
||||
├── README.md
|
||||
└── run.bat
|
||||
```
|
||||
|
||||
## Команды бота
|
||||
|
||||
### Пользовательские
|
||||
|
||||
- `/start` — открыть стартовую панель
|
||||
|
||||
### Админские
|
||||
|
||||
- `Админ-панель` в UI — сводка по боту, поиск пользователя, полная синхронизация, быстрые переходы в рабочие чаты
|
||||
- `/lookup <uuid|id|username|short_uuid>` — найти и синхронизировать пользователя
|
||||
- `/sync_all` — массовая синхронизация пользователей Remnawave в MariaDB
|
||||
|
||||
## База данных
|
||||
|
||||
### Основные таблицы
|
||||
|
||||
- `telegram_users`
|
||||
- Telegram-пользователи, взаимодействовавшие с ботом
|
||||
|
||||
- `remnawave_users`
|
||||
- локальный кэш пользователей Remnawave
|
||||
|
||||
- `internal_squads`
|
||||
- справочник внутренних групп
|
||||
|
||||
- `remnawave_user_internal_squads`
|
||||
- связь many-to-many пользователей и групп
|
||||
|
||||
- `subscription_request_logs`
|
||||
- история запросов подписки
|
||||
|
||||
- `support_tickets`
|
||||
- тикеты поддержки
|
||||
|
||||
- `referral_codes`
|
||||
- персональные коды пользователей
|
||||
|
||||
- `referral_invites`
|
||||
- кто кого пригласил
|
||||
|
||||
- `referral_bonuses`
|
||||
- начисленные и ожидающие применения бонусы реферерам
|
||||
|
||||
- `payment_orders`
|
||||
- ручные платежи, статусы и выданные доступы
|
||||
|
||||
### Статусы заказов оплаты
|
||||
|
||||
Сейчас используются:
|
||||
|
||||
- `PENDING` — заказ создан, чек ещё не отправлен
|
||||
- `REVIEW` — чек отправлен на проверку
|
||||
- `REJECTED` — платёж отклонён модератором
|
||||
- `FULFILLED` — доступ выдан или продлён
|
||||
|
||||
## Реферальная система
|
||||
|
||||
Что реализовано:
|
||||
|
||||
- каждому пользователю создаётся персональный код
|
||||
- можно сгенерировать ссылку вида `https://t.me/<bot_username>?start=ref_<CODE>`
|
||||
- сам переход по ссылке скидку не активирует
|
||||
- чтобы получить скидку, пользователь должен вручную ввести код до оплаты
|
||||
- размер скидки задаётся в `REFERRAL_DISCOUNT_PERCENT`
|
||||
- за каждую подтверждённую оплату по рефкоду реферер получает бонус в днях
|
||||
- размер бонуса задаётся в `REFERRAL_BONUS_DAYS`
|
||||
|
||||
## Тикеты поддержки
|
||||
|
||||
Сценарий:
|
||||
|
||||
1. Пользователь открывает раздел `Поддержка`
|
||||
2. Нажимает `Создать тикет`
|
||||
3. Отправляет следующим сообщением вопрос или описание проблемы
|
||||
4. Бот отправляет тикет в support-чат
|
||||
5. Админ или модератор отвечает реплаем на сообщение с тикетом
|
||||
6. Бот доставляет ответ пользователю в личный чат
|
||||
|
||||
Доступ к ответу на тикет есть только у:
|
||||
|
||||
- пользователей из `BOT_ADMIN_IDS`
|
||||
- пользователей из `BOT_MODERATOR_IDS`
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
Полный шаблон лежит в `.env.example`.
|
||||
|
||||
Критический минимум для запуска:
|
||||
|
||||
- `BOT_TOKEN`
|
||||
- `REMNAWAVE_BASE_URL`
|
||||
- `REMNAWAVE_API_TOKEN`
|
||||
- `DB_HOST`
|
||||
- `DB_PORT`
|
||||
- `DB_NAME`
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
- `PAYMENT_INTERNAL_SQUAD_UUIDS`
|
||||
|
||||
Для полного сценария поддержки и оплаты также нужны:
|
||||
|
||||
- `BOT_PUBLIC_USERNAME`
|
||||
- `BOT_SUPPORT_TICKET_LINK` или `BOT_SUPPORT_TICKET_CHAT_ID`
|
||||
- `PAYMENT_TRANSFER_TEXT`
|
||||
- `PAYMENT_REVIEW_LINK` или `PAYMENT_REVIEW_CHAT_ID`
|
||||
|
||||
## Как настроить `.env`
|
||||
|
||||
1. Скопировать шаблон:
|
||||
|
||||
```bat
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
2. Заполнить обязательные значения
|
||||
|
||||
3. Проверить:
|
||||
|
||||
- корректен ли `BOT_TOKEN`
|
||||
- доступен ли `REMNAWAVE_BASE_URL`
|
||||
- валиден ли `REMNAWAVE_API_TOKEN`
|
||||
- существует ли MariaDB и есть ли права на создание БД, если `CREATE_DATABASE_ON_START=true`
|
||||
- указаны ли реальные UUID групп в `PAYMENT_INTERNAL_SQUAD_UUIDS`
|
||||
- указаны ли реальные реквизиты в `PAYMENT_TRANSFER_TEXT`
|
||||
|
||||
## Пример важных настроек
|
||||
|
||||
```env
|
||||
BOT_BRAND_NAME=OREOL VPN
|
||||
BOT_PUBLIC_USERNAME=oreol_vpn_bot
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_NAME=oreolvpn
|
||||
DB_USER=oreolvpn
|
||||
DB_PASSWORD=strong_password
|
||||
|
||||
PAYMENT_PLANS=30:250,180:600,365:1000
|
||||
PAYMENT_TRANSFER_TEXT=Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN
|
||||
PAYMENT_REVIEW_LINK=https://t.me/c/3646494169/56/57
|
||||
PAYMENT_INTERNAL_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111
|
||||
REFERRAL_DISCOUNT_PERCENT=5
|
||||
PAYMENT_USERNAME_PREFIX=Oreol
|
||||
```
|
||||
|
||||
## Поддержка Telegram-ссылок `t.me/c/...`
|
||||
|
||||
Проект умеет разбирать приватные ссылки Telegram вида:
|
||||
|
||||
```text
|
||||
https://t.me/c/3646494169/56/57
|
||||
```
|
||||
|
||||
Разбор происходит так:
|
||||
|
||||
- chat id = `-1003646494169`
|
||||
- thread id = `56`
|
||||
- последний сегмент `57` — это message id, для маршрутизации топика он не нужен
|
||||
|
||||
Это используется для:
|
||||
|
||||
- `BOT_SUPPORT_TICKET_LINK`
|
||||
- `PAYMENT_REVIEW_LINK`
|
||||
|
||||
## Как запускать
|
||||
|
||||
### Вариант 1. Windows через `run.bat`
|
||||
|
||||
```bat
|
||||
run.bat
|
||||
```
|
||||
|
||||
Что делает батник:
|
||||
|
||||
- переходит в папку проекта
|
||||
- проверяет `.env`
|
||||
- создаёт `.venv`, если его нет
|
||||
- ставит зависимости
|
||||
- запускает `main.py`
|
||||
|
||||
### Вариант 2. Ручной локальный запуск
|
||||
|
||||
```bat
|
||||
py -3 -m venv .venv
|
||||
.venv\Scripts\python -m pip install -e .[dev]
|
||||
.venv\Scripts\python main.py
|
||||
```
|
||||
|
||||
### Вариант 3. Docker
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Это полный запуск вместе с MariaDB. База данных уже есть в проекте: сервис `db` описан в `docker-compose.yml`, данные сохраняются в volume `mariadb_data`.
|
||||
|
||||
#### Что нужно установить заранее
|
||||
|
||||
- Docker Desktop на Windows/macOS
|
||||
- или Docker Engine + Docker Compose plugin на Linux
|
||||
|
||||
Проверьте, что команды доступны:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
#### Ubuntu Server 24.04: установка Docker Engine
|
||||
|
||||
Для `Ubuntu Server 24.04` рекомендуется ставить Docker Engine из официального Docker-репозитория.
|
||||
|
||||
1. Обновить пакеты и поставить базовые утилиты:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y ca-certificates curl git
|
||||
```
|
||||
|
||||
2. Добавить официальный GPG-ключ Docker:
|
||||
|
||||
```bash
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
```
|
||||
|
||||
3. Подключить Docker repository:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
|
||||
Types: deb
|
||||
URIs: https://download.docker.com/linux/ubuntu
|
||||
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
|
||||
Components: stable
|
||||
Architectures: $(dpkg --print-architecture)
|
||||
Signed-By: /etc/apt/keyrings/docker.asc
|
||||
EOF
|
||||
```
|
||||
|
||||
4. Установить Docker Engine и Compose plugin:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
```
|
||||
|
||||
5. Включить Docker после перезагрузки:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable docker --now
|
||||
```
|
||||
|
||||
6. Разрешить запуск Docker без `sudo`:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
После этого выйдите из SSH-сессии и зайдите снова, либо выполните:
|
||||
|
||||
```bash
|
||||
newgrp docker
|
||||
```
|
||||
|
||||
7. Проверить установку:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker compose version
|
||||
docker ps
|
||||
```
|
||||
|
||||
Если проект будет стоять на VPS или выделенном сервере, этого достаточно: отдельный Docker Desktop на Linux Server не нужен.
|
||||
|
||||
#### Что нужно подготовить перед первым запуском
|
||||
|
||||
1. Скопировать пример конфига:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
На Windows PowerShell:
|
||||
1. Создайте отдельную БД MariaDB и пользователя с правами на неё. Укажите собственные DB_*.
|
||||
2. Скопируйте .env.example в .env и заполните токены, адрес панели, Telegram ID администратора и настройки чатов/оплаты/сквадов. Не отправляйте заполненный .env другим людям.
|
||||
3. Подготовьте карту ID, описанную ниже. Без неё режим v3 не запустится.
|
||||
4. В терминале из каталога проекта:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
python -m venv .venv
|
||||
.venv\Scripts\python.exe -m pip install .
|
||||
.venv\Scripts\python.exe main.py
|
||||
```
|
||||
|
||||
2. Открыть `.env` и заполнить обязательные значения:
|
||||
Для Linux используйте `.venv/bin/python`. БД должна быть создана заранее: в примере CREATE_DATABASE_ON_START=false, CREATE_TABLES_ON_START=true.
|
||||
|
||||
- `BOT_TOKEN`
|
||||
- `BOT_ADMIN_IDS`
|
||||
- `REMNAWAVE_BASE_URL`
|
||||
- `REMNAWAVE_API_TOKEN`
|
||||
- `DB_NAME`
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
- `DB_ROOT_PASSWORD`
|
||||
- `PAYMENT_INTERNAL_SQUAD_UUIDS`
|
||||
Для разработки: `python -m pip install ".[dev]"` и `python -m pytest`. Исходные тесты включены; при изменении примерных настроек сверяйте ожидаемые значения тестов.
|
||||
|
||||
Обычно ещё сразу настраивают:
|
||||
## Обязательная карта ID для v3
|
||||
|
||||
- `BOT_PUBLIC_USERNAME`
|
||||
- `BOT_SUPPORT_URL`
|
||||
- `BOT_SUPPORT_TICKET_LINK` или `BOT_SUPPORT_TICKET_CHAT_ID`
|
||||
- `PAYMENT_TRANSFER_TEXT`
|
||||
- `PAYMENT_REVIEW_LINK` или `PAYMENT_REVIEW_CHAT_ID`
|
||||
`app/legacy_user_ids.json` специально НЕ включён: он содержал реальные данные пользователей. В текущем коде v3 требует непустой JSON-массив соответствий полей `id` (числовой ID пользователя панели) и `uuid` (прежний локальный UUID того же пользователя). Карта должна принадлежать вашей панели и точно совпадать с существующим кэшем бота. Код проверяет соответствия и исторические записи перед началом polling. Не копируйте чужую карту, не подставляйте случайные UUID и не удаляйте защиту ради запуска.
|
||||
|
||||
#### Важный момент по базе данных в Docker
|
||||
При миграции собственной установки v2→v3 сохраните свои точные соответствия UUID→числовой ID ДО миграции и установите карту на место указанного файла; храните её вместе с резервной копией БД.
|
||||
|
||||
При запуске через `docker compose` контейнер бота сам получает:
|
||||
Для полностью новой v3-установки этот миграционный вариант кода тоже требует непустую карту. Готового безопасного начального seed в архиве нет. Перед первым запуском понадобится отдельно согласованная инициализация идентификаторов для вашей панели/пустой БД либо доработка режима чистой установки. Пустой массив не принимается. Архив не следует считать готовым к запуску на чистой v3-панели.
|
||||
|
||||
```env
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
```
|
||||
## Что отсутствует
|
||||
|
||||
Это уже задано в `docker-compose.yml`. В `.env` для Docker главное задать:
|
||||
Рабочий .env, карта пользователей, БД/дампы, логи, резервные копии и серверные токены исключены. Серверный docker-compose.yml, настройки mihomo/subconverter, изображения и другие внешние assets локально не выгружались и в архиве отсутствуют. Стартовая картинка в примере выключена; при необходимости добавьте свою и настройте путь. Dockerfile приложен, но сам по себе не создаёт БД и не заменяет серверный compose.
|
||||
|
||||
- `DB_NAME`
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
- `DB_ROOT_PASSWORD`
|
||||
|
||||
Если вы запускаете проект только в Docker, `DB_HOST` и `DB_PORT` можно не менять вручную.
|
||||
|
||||
`DB_ROOT_PASSWORD` нужен только контейнеру MariaDB. Сам бот подключается к базе исключительно через:
|
||||
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
|
||||
Это важно:
|
||||
|
||||
- `DB_ROOT_PASSWORD` не используется приложением для SQLAlchemy-подключения
|
||||
- если в `.env` указать `DB_USER=root`, но оставить `DB_PASSWORD` пустым, бот попытается зайти как `root` без пароля и упадёт с `Access denied`
|
||||
|
||||
Для production рекомендуется не использовать `root` для бота, а создать отдельного пользователя БД через:
|
||||
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
|
||||
MariaDB в текущем `docker-compose.yml` не публикуется наружу на хост, чтобы база не торчала во внешний интернет по `3306`. Бот подключается к ней по внутренней Docker-сети через hostname `db`.
|
||||
|
||||
#### Первый запуск
|
||||
|
||||
Запустите сборку и контейнеры:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Будут подняты два сервиса:
|
||||
|
||||
- `db` — MariaDB 11.7
|
||||
- `bot` — приложение на Python
|
||||
|
||||
Полный сценарий для Ubuntu Server обычно выглядит так:
|
||||
|
||||
```bash
|
||||
git clone <URL_репозитория> telegabot
|
||||
cd telegabot
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
#### Как проверить, что всё поднялось
|
||||
|
||||
Посмотреть список контейнеров:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Посмотреть логи бота:
|
||||
|
||||
```bash
|
||||
docker compose logs -f bot
|
||||
```
|
||||
|
||||
Посмотреть логи базы:
|
||||
|
||||
```bash
|
||||
docker compose logs -f db
|
||||
```
|
||||
|
||||
Проверить, что бот видит базу и база отвечает:
|
||||
|
||||
```bash
|
||||
docker compose exec db mariadb -u"$DB_USER" -p"$DB_PASSWORD" -e "SHOW DATABASES;"
|
||||
```
|
||||
|
||||
Если нужно зайти в MariaDB вручную:
|
||||
|
||||
```bash
|
||||
docker compose exec db mariadb -u"$DB_USER" -p"$DB_PASSWORD" "$DB_NAME"
|
||||
```
|
||||
|
||||
Если всё настроено правильно, сценарий такой:
|
||||
|
||||
1. поднимается `db`
|
||||
2. healthcheck MariaDB становится `healthy`
|
||||
3. стартует `bot`
|
||||
4. бот при старте создаёт БД и таблицы, если включены:
|
||||
- `CREATE_DATABASE_ON_START=true`
|
||||
- `CREATE_TABLES_ON_START=true`
|
||||
|
||||
#### Остановка и повторный запуск
|
||||
|
||||
Остановить контейнеры:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Запустить снова без пересборки:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Пересобрать после изменения кода:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
#### Как удалить всё вместе с базой
|
||||
|
||||
Если нужен полный сброс, включая MariaDB volume:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
После этого база будет создана заново при следующем запуске.
|
||||
|
||||
#### Обновление на Ubuntu Server
|
||||
|
||||
Если вы обновили код проекта:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Если меняли только `.env`, обычно достаточно:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### Если бот не стартует
|
||||
|
||||
Проверьте по пунктам:
|
||||
|
||||
- заполнен ли `BOT_TOKEN`
|
||||
- доступен ли `REMNAWAVE_BASE_URL`
|
||||
- корректен ли `REMNAWAVE_API_TOKEN`
|
||||
- заполнен ли `PAYMENT_INTERNAL_SQUAD_UUIDS`
|
||||
- не пустые ли `DB_PASSWORD` и `DB_ROOT_PASSWORD`
|
||||
- есть ли у сервиса `db` статус `healthy` в `docker compose ps`
|
||||
|
||||
Если видите ошибку вида:
|
||||
|
||||
```text
|
||||
Access denied for user 'root' ... (using password: NO)
|
||||
```
|
||||
|
||||
проверьте:
|
||||
|
||||
- не стоит ли в `.env` `DB_USER=root`
|
||||
- не пустой ли `DB_PASSWORD`
|
||||
- совпадают ли `DB_USER` и `DB_PASSWORD` с пользователем MariaDB, который был создан при первом старте контейнера
|
||||
|
||||
Если контейнер `db` уже один раз поднимался с неправильными DB-переменными, volume MariaDB мог сохранить старого пользователя и старый пароль. Тогда есть два варианта:
|
||||
|
||||
- если данных ещё не жалко: `docker compose down -v` и затем `docker compose up -d --build`
|
||||
- если данные нужно сохранить: зайти в MariaDB и вручную создать/выдать права нужному пользователю
|
||||
|
||||
Если хотите открыть MariaDB наружу для внешнего клиента, это нужно делать осознанно: добавьте `ports` обратно в сервис `db` и отдельно ограничьте доступ через firewall или private network.
|
||||
|
||||
#### Минимальный сценарий запуска в Docker
|
||||
|
||||
1. Скопировать `.env.example` в `.env`
|
||||
2. Заполнить Telegram, Remnawave и DB-переменные
|
||||
3. Выполнить `docker compose up -d --build`
|
||||
4. Проверить `docker compose logs -f bot`
|
||||
|
||||
## Проверка проекта
|
||||
|
||||
### Синтаксис
|
||||
|
||||
```bat
|
||||
py -3 -m compileall app main.py tests
|
||||
```
|
||||
|
||||
### Тесты
|
||||
|
||||
```bat
|
||||
.venv\Scripts\python -m pytest
|
||||
```
|
||||
|
||||
Если основная `.venv` невалидна из-за переезда проекта между машинами, пересоздай её.
|
||||
|
||||
## Что уже протестировано
|
||||
|
||||
Покрыто тестами:
|
||||
|
||||
- парсинг конфигурации
|
||||
- разбор support/review ссылок Telegram
|
||||
- парсинг тарифов
|
||||
- форматтеры текста
|
||||
- UI панели
|
||||
- базовые методы `RemnawaveApiClient`
|
||||
|
||||
Последняя локальная проверка в этой сетевой копии:
|
||||
|
||||
- `py -3 -m compileall app main.py tests` — успешно
|
||||
- `pytest` — `26 passed`
|
||||
|
||||
## Известные нюансы
|
||||
|
||||
### 1. Перенос `.venv` между машинами
|
||||
|
||||
Если скопировать проект вместе с уже готовой `.venv` в другую папку, на другой компьютер или на сетевой диск, окружение может ссылаться на старый путь Python.
|
||||
|
||||
Типичная ошибка:
|
||||
|
||||
```text
|
||||
did not find executable at ...python.exe
|
||||
```
|
||||
|
||||
Решение:
|
||||
|
||||
1. удалить `.venv`
|
||||
2. создать её заново
|
||||
3. снова установить зависимости
|
||||
|
||||
### 2. Ошибка `Unknown database`
|
||||
|
||||
Если MariaDB доступна, но самой базы из `DB_NAME` ещё нет, можно увидеть:
|
||||
|
||||
```text
|
||||
OperationalError: (1049, "Unknown database '...'" )
|
||||
```
|
||||
|
||||
Что делать:
|
||||
|
||||
- оставить `CREATE_DATABASE_ON_START=true`, если у пользователя есть права на создание БД
|
||||
- либо создать БД вручную
|
||||
|
||||
### 3. Прокси Telegram
|
||||
|
||||
Для `TELEGRAM_PROXY_URL` подходят обычные proxy URL:
|
||||
|
||||
- `http://user:pass@host:port`
|
||||
- `socks5://host:port`
|
||||
|
||||
Не подходят клиентские ссылки вроде:
|
||||
|
||||
- `vless://...`
|
||||
- `vmess://...`
|
||||
- `trojan://...`
|
||||
- `tg://proxy?...` для aiogram напрямую
|
||||
|
||||
### 4. Review-чат оплаты должен быть настроен
|
||||
|
||||
Если `PAYMENT_REVIEW_LINK` и `PAYMENT_REVIEW_CHAT_ID` пустые, пользователь сможет выбрать тариф, но бот не сможет отправить чек на модерацию.
|
||||
|
||||
### 5. Internal squads обязательны для выдачи доступа
|
||||
|
||||
Если `PAYMENT_INTERNAL_SQUAD_UUIDS` пустой, бот не сможет корректно создать доступ в Remnawave.
|
||||
|
||||
## Безопасность
|
||||
|
||||
Нельзя публиковать:
|
||||
|
||||
- реальный `.env`
|
||||
- `BOT_TOKEN`
|
||||
- `REMNAWAVE_API_TOKEN`
|
||||
- `REMNAWAVE_CADDY_API_KEY`
|
||||
- банковские реквизиты из рабочего `PAYMENT_TRANSFER_TEXT`
|
||||
|
||||
Передавать между сессиями безопасно:
|
||||
|
||||
- код проекта
|
||||
- `README.md`
|
||||
- `.env.example`
|
||||
|
||||
## Что логично делать дальше
|
||||
|
||||
Следующие разумные шаги по проекту:
|
||||
|
||||
- добавить миграции через Alembic
|
||||
- хранить отдельный audit log по модерации оплат
|
||||
- сделать уведомление модераторам о новых чеках
|
||||
- добавить историю заказов пользователя в панели
|
||||
- сделать админ-панель со списком платежей
|
||||
- добавить продление конкретного существующего аккаунта по выбору, если у пользователя их несколько
|
||||
- добавить отдельный статус `APPROVED_BUT_NOT_DELIVERED`, если бот не смог отправить результат пользователю
|
||||
- покрыть тестами `PaymentService` и FSM оплаты
|
||||
|
||||
## Быстрый handoff
|
||||
|
||||
Если проект открывает новая сессия, порядок такой:
|
||||
|
||||
1. Прочитать этот README
|
||||
2. Проверить `.env`
|
||||
3. Проверить, рабочая ли `.venv`
|
||||
4. Убедиться, что доступны MariaDB и Remnawave
|
||||
5. Запустить `run.bat` или `python main.py`
|
||||
6. Проверить руками:
|
||||
- `/start`
|
||||
- открытие раздела подписки через кнопку в панели
|
||||
- ввод реферального кода
|
||||
- отправку чека
|
||||
- подтверждение оплаты модератором
|
||||
- выдачу ссылки пользователю
|
||||
- поддержку через тикет
|
||||
Исходные файлы сохранены; в экспортной копии заменены личная ссылка Telegram в настройках, бренд/префикс по умолчанию и примерные цены. Бизнес-логика и обязательная проверка карты не менялись.
|
||||
|
||||
+7
-6
@@ -48,7 +48,7 @@ class Settings(BaseSettings):
|
||||
bot_admin_ids_raw: str = Field(default="", validation_alias="BOT_ADMIN_IDS")
|
||||
bot_moderator_ids_raw: str = Field(default="", validation_alias="BOT_MODERATOR_IDS")
|
||||
telegram_proxy_url: str = Field(default="", validation_alias="TELEGRAM_PROXY_URL")
|
||||
bot_brand_name: str = Field(default="OREOL VPN", validation_alias="BOT_BRAND_NAME")
|
||||
bot_brand_name: str = Field(default="Example VPN", validation_alias="BOT_BRAND_NAME")
|
||||
bot_public_username: str = Field(default="", validation_alias="BOT_PUBLIC_USERNAME")
|
||||
bot_support_url: str = Field(default="", validation_alias="BOT_SUPPORT_URL")
|
||||
bot_terms_url: str = Field(default="", validation_alias="BOT_TERMS_URL")
|
||||
@@ -68,6 +68,7 @@ class Settings(BaseSettings):
|
||||
bot_start_image_path: str = Field(default="", validation_alias="BOT_START_IMAGE_PATH")
|
||||
|
||||
remnawave_base_url: str = Field(validation_alias="REMNAWAVE_BASE_URL")
|
||||
remnawave_api_version: int = Field(default=2, ge=2, le=3, validation_alias="REMNAWAVE_API_VERSION")
|
||||
remnawave_api_token: str = Field(default="", validation_alias="REMNAWAVE_API_TOKEN")
|
||||
remnawave_caddy_api_key: str = Field(default="", validation_alias="REMNAWAVE_CADDY_API_KEY")
|
||||
remnawave_timeout_seconds: float = Field(
|
||||
@@ -97,7 +98,7 @@ class Settings(BaseSettings):
|
||||
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
|
||||
|
||||
payment_plans_raw: str = Field(
|
||||
default="30:250,180:600,365:1000",
|
||||
default="30:100",
|
||||
validation_alias="PAYMENT_PLANS",
|
||||
)
|
||||
payment_product_plans_raw: str = Field(
|
||||
@@ -122,7 +123,7 @@ class Settings(BaseSettings):
|
||||
validation_alias="PAYMENT_REVIEW_THREAD_ID",
|
||||
)
|
||||
broadcast_results_link: str = Field(
|
||||
default="https://t.me/c/3646494169/67/68",
|
||||
default="",
|
||||
validation_alias="BROADCAST_RESULTS_LINK",
|
||||
)
|
||||
broadcast_results_chat_id_raw: str = Field(
|
||||
@@ -143,7 +144,7 @@ class Settings(BaseSettings):
|
||||
)
|
||||
payment_plan_code: str = Field(default="vpn_30d", validation_alias="PAYMENT_PLAN_CODE")
|
||||
payment_plan_title: str = Field(
|
||||
default="OREOL VPN на 30 дней",
|
||||
default="Example VPN на 30 дней",
|
||||
validation_alias="PAYMENT_PLAN_TITLE",
|
||||
)
|
||||
payment_plan_description: str = Field(
|
||||
@@ -184,7 +185,7 @@ class Settings(BaseSettings):
|
||||
validation_alias="PAYMENT_SUPPORT_TEXT",
|
||||
)
|
||||
payment_username_prefix: str = Field(
|
||||
default="Oreol",
|
||||
default="Example",
|
||||
validation_alias="PAYMENT_USERNAME_PREFIX",
|
||||
)
|
||||
payment_user_tag: str = Field(default="BOT", validation_alias="PAYMENT_USER_TAG")
|
||||
@@ -323,7 +324,7 @@ class Settings(BaseSettings):
|
||||
normalized = "".join(
|
||||
ch for ch in self.payment_username_prefix.strip() if ch.isalnum() or ch in "_-"
|
||||
)
|
||||
return normalized[:12] or "Oreol"
|
||||
return normalized[:12] or "Example"
|
||||
|
||||
@cached_property
|
||||
def payment_user_tag_normalized(self) -> str | None:
|
||||
|
||||
+19
@@ -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,
|
||||
|
||||
@@ -71,7 +71,7 @@ class BotConfigSnapshot:
|
||||
normalized = "".join(
|
||||
ch for ch in self.payment_username_prefix.strip() if ch.isalnum() or ch in "_-"
|
||||
)
|
||||
return normalized[:12] or "Oreol"
|
||||
return normalized[:12] or "Example"
|
||||
|
||||
@property
|
||||
def payment_user_tag_normalized(self) -> str | None:
|
||||
@@ -323,7 +323,7 @@ class BotConfigService(StaticBotConfigService):
|
||||
section="brand",
|
||||
prompt="Введите новое название бренда.",
|
||||
description="Используется в главной панели и пользовательских сообщениях.",
|
||||
placeholder="OREOL VPN",
|
||||
placeholder="Example VPN",
|
||||
),
|
||||
"bot_public_username": BotConfigFieldSpec(
|
||||
key="bot_public_username",
|
||||
@@ -331,7 +331,7 @@ class BotConfigService(StaticBotConfigService):
|
||||
section="brand",
|
||||
prompt="Введите username бота без @. Для очистки отправьте `-`.",
|
||||
description="Нужен для генерации реферальных ссылок.",
|
||||
placeholder="oreol_vpn_bot",
|
||||
placeholder="example_vpn_bot",
|
||||
),
|
||||
"bot_start_image_enabled": BotConfigFieldSpec(
|
||||
key="bot_start_image_enabled",
|
||||
@@ -450,7 +450,7 @@ class BotConfigService(StaticBotConfigService):
|
||||
section="links",
|
||||
prompt="Введите приватную ссылку на чат/топик, куда будут приходить ответы опросов. Для очистки отправьте `-`.",
|
||||
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
|
||||
placeholder="https://t.me/c/3646494169/67/68",
|
||||
placeholder="https://t.me/c/1234567890/67/68",
|
||||
),
|
||||
"payment_transfer_text": BotConfigFieldSpec(
|
||||
key="payment_transfer_text",
|
||||
@@ -458,7 +458,7 @@ class BotConfigService(StaticBotConfigService):
|
||||
section="texts",
|
||||
prompt="Введите текст с реквизитами или инструкцией перевода.",
|
||||
description="Показывается пользователю после выбора тарифа.",
|
||||
placeholder="Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN",
|
||||
placeholder="Карта 0000 0000 0000 0000; банк Пример; получатель Example VPN",
|
||||
),
|
||||
"payment_support_text": BotConfigFieldSpec(
|
||||
key="payment_support_text",
|
||||
@@ -514,7 +514,7 @@ class BotConfigService(StaticBotConfigService):
|
||||
section="provisioning",
|
||||
prompt="Введите префикс логина для новых пользователей Remnawave.",
|
||||
description="Итоговый логин строится как `<prefix>-<telegram_id>-<name>`.",
|
||||
placeholder="Oreol",
|
||||
placeholder="Example",
|
||||
),
|
||||
"payment_user_tag": BotConfigFieldSpec(
|
||||
key="payment_user_tag",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -75,19 +75,6 @@ def test_format_issued_access_contains_subscription_data() -> None:
|
||||
assert "/me</code>" not in text
|
||||
|
||||
|
||||
def test_format_issued_access_includes_custom_success_text() -> None:
|
||||
text = format_issued_access(
|
||||
subscription_url="https://example.com/sub",
|
||||
expire_at=datetime(2026, 5, 21, 18, 0),
|
||||
traffic_limit_bytes=0,
|
||||
brand_name="OREOL VPN",
|
||||
success_text="Откройте приложение и импортируйте ссылку <важно>.",
|
||||
)
|
||||
|
||||
assert "Откройте приложение и импортируйте ссылку" in text
|
||||
assert "<важно>" in text
|
||||
|
||||
|
||||
def test_format_issued_access_for_renewal_mentions_extension() -> None:
|
||||
text = format_issued_access(
|
||||
subscription_url="https://example.com/sub",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')])
|
||||
@@ -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')])
|
||||
Reference in New Issue
Block a user