Compare commits

...

25 Commits

Author SHA1 Message Date
Egor a61fb10784 Update Dockerfile 2025-11-07 03:04:36 +03:00
Egor 8d83881cf0 Update docker-registry.yml 2025-11-07 03:04:25 +03:00
Egor d624df5f65 Update docker-hub.yml 2025-11-07 03:04:06 +03:00
Egor a96a7de784 Update README.md 2025-11-07 02:55:42 +03:00
Egor fa188e04c5 Add files via upload 2025-11-07 02:32:46 +03:00
Egor e8c95ece8d Update README.md 2025-11-07 02:24:13 +03:00
Egor bc0f4adb7a Update README.md 2025-11-07 02:23:10 +03:00
Egor 3eb5a71e8d Merge pull request #1747 from Fr1ngg/dev4
Бот на Вебхуке Без смс и регистрации
2025-11-07 01:47:53 +03:00
Egor 37c97de5d6 Update README.md 2025-11-07 01:37:42 +03:00
Egor 3624832ed8 Update README.md 2025-11-07 01:37:06 +03:00
Egor 9ee39d5f71 Update README.md 2025-11-07 01:36:17 +03:00
Egor 3a0e558b6f Update README.md 2025-11-07 01:34:05 +03:00
Egor ca083c216e Merge pull request #1746 from Fr1ngg/bedolaga/fix-notification-sending-error
Fix trial expiration notification loading for monitoring service
2025-11-07 01:12:17 +03:00
Egor 8b9c1d1cc6 Fix trial notification promo group loading 2025-11-07 01:01:33 +03:00
Egor 78830a7884 Merge pull request #1745 from Fr1ngg/bedolaga/add-telegram-webhook-and-both-mode-agf2n2
Expose unified health status alongside admin API
2025-11-06 18:09:19 +03:00
Egor 9fe7d0592f Expose unified health status alongside admin API 2025-11-06 18:02:08 +03:00
Egor 6a3e3c6fc4 Merge pull request #1741 from Fr1ngg/revert-1740-bedolaga/add-telegram-webhook-and-both-mode
Revert "Restore FastAPI docs endpoints for webhook mode"
2025-11-06 17:30:04 +03:00
Egor 54bfff0ed6 Revert "Restore FastAPI docs endpoints for webhook mode" 2025-11-06 17:28:06 +03:00
Egor 6ee305cbba Merge pull request #1740 from Fr1ngg/bedolaga/add-telegram-webhook-and-both-mode
Restore FastAPI docs endpoints for webhook mode
2025-11-06 17:27:56 +03:00
Egor 340041ddae Restore FastAPI docs endpoints for webhook mode 2025-11-06 17:27:39 +03:00
Egor 8a3628a6c5 Merge pull request #1738 from Fr1ngg/dev4
Dev4
2025-11-06 13:54:27 +03:00
Egor 8ac3350ac6 Merge pull request #1735 from reshifter1/main
Проверка промокода при вводе реферального кода + фикс ебучего lazy loading
2025-11-06 13:46:52 +03:00
Pavel Stryuk a507ef9966 Промокоды на место реферального кода 2025-11-06 11:03:50 +01:00
Pavel Stryuk 113405ed71 Промокоды на место реферального кода 2025-11-06 09:26:24 +01:00
Egor 1bdf1a0f4c Update README.md 2025-11-06 09:46:33 +03:00
30 changed files with 3195 additions and 981 deletions
+22
View File
@@ -188,6 +188,8 @@ TRIBUTE_API_KEY=
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_HOST=0.0.0.0
# Примечание: индивидуальные *_WEBHOOK_PORT используются только для старой схемы запуска.
# В режиме единого FastAPI сервера оставьте эти значения по умолчанию.
TRIBUTE_WEBHOOK_PORT=8081
# YooKassa (https://yookassa.ru)
@@ -273,6 +275,8 @@ CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
CRYPTOBOT_TESTNET=false
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
# Эти порты используются только при ручном запуске старого aiohttp сервера.
# В новой конфигурации все вебхуки обрабатываются через порт 8080.
CRYPTOBOT_WEBHOOK_PORT=8081
CRYPTOBOT_DEFAULT_ASSET=USDT
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
@@ -349,6 +353,7 @@ CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_STATIC_PATH=miniapp
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
@@ -449,3 +454,20 @@ LOG_FILE=logs/bot.log
DEBUG=false
WEBHOOK_URL=
WEBHOOK_PATH=/webhook
WEBHOOK_SECRET_TOKEN=
WEBHOOK_DROP_PENDING_UPDATES=true
WEBHOOK_MAX_QUEUE_SIZE=1024
WEBHOOK_WORKERS=4
WEBHOOK_ENQUEUE_TIMEOUT=0.1
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
BOT_RUN_MODE=polling # polling, webhook или both
# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР =====
WEB_API_ENABLED=false
WEB_API_HOST=0.0.0.0
WEB_API_PORT=8080
WEB_API_ALLOWED_ORIGINS=*
WEB_API_DOCS_ENABLED=false
WEB_API_DEFAULT_TOKEN=
WEB_API_DEFAULT_TOKEN_NAME=Bootstrap Token
MINIAPP_STATIC_PATH=miniapp
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.5.8-$(git rev-parse --short HEAD)"
VERSION="v2.6.0-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.5.8-dev-$(git rev-parse --short HEAD)"
VERSION="v2.6.0-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.5.8-pr-$(git rev-parse --short HEAD)"
VERSION="v2.6.0-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.5.8-$(git rev-parse --short HEAD)"
VERSION="v2.6.0-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.5.8-dev-$(git rev-parse --short HEAD)"
VERSION="v2.6.0-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.5.8-pr-$(git rev-parse --short HEAD)"
VERSION="v2.6.0-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+3 -3
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.5.8"
ARG VERSION="v2.6.0"
ARG BUILD_DATE
ARG VCS_REF
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
BUILD_DATE=${BUILD_DATE} \
VCS_REF=${VCS_REF}
EXPOSE 8080 8081 8082
EXPOSE 8080
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
@@ -57,6 +57,6 @@ LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.vendor="fr1ngg"
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["python", "main.py"]
+299 -498
View File
@@ -73,14 +73,6 @@
## 🚀 Быстрый старт
### 🧙‍♂️ Автоустановка через `install_bot.sh`
Скрипт-установщик берёт на себя подготовку окружения, настройку конфигурации и дальнейшее обслуживание бота. Он работает поверх Docker Compose и требует заранее установить:
- **Docker Engine** и **Docker Compose plugin** (2.20+);
- **Git** и **Bash** (по умолчанию есть в большинстве Linux дистрибутивов);
- `openssl` (используется для генерации токенов, но не обязателен — при отсутствии скрипт использует `urandom`).
```bash
# 1. Скачай репозиторий
git clone https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot.git
@@ -91,32 +83,18 @@ mkdir -p ./logs ./data ./data/backups ./data/referral_qr
chmod -R 755 ./logs ./data
sudo chown -R 1000:1000 ./logs ./data
# 3. Запусти мастер установки
chmod +x install_bot.sh
./install_bot.sh
# 3. make up # Поднять контейнеры
make help # Показать все команды
make down # Остановить контейнеры
make reload # Перезапустить
make reload-follow # Перезапустить с логами
make test # Запустить тесты
```
На первом запуске мастер:
1. Спросит путь установки и сохранит его в `./.bot_install_state` — можно оставлять путь по умолчанию (текущая директория).
2. Поможет собрать `.env`: запросит обязательные токены (бот, Remnawave, админы), при необходимости сгенерирует Web API и PostgreSQL пароли, предложит авторизацию Basic Auth или eGames secret.
3. Подготовит структуру каталогов (`logs`, `data`, `backups` и т. д.) и проверит, что Docker готов к запуску.
4. Создаст (или обновит) `docker-compose.yml`, настроит внешнюю сеть `bot_network`, чтобы дальнейшие сервисы (например, Caddy) могли подключаться.
5. Запустит контейнеры бота, PostgreSQL и Redis и выведет их статус.
После установки повторный запуск `./install_bot.sh` открывает **интерактивное меню управления**:
- 📊 Мониторинг состояния контейнеров и ресурсов
- ⚙️ Управление сервисами (запуск/остановка/пересборка)
- 📋 Просмотр и поиск по логам
- 🔄 Обновление проекта из Git с автоматическим бэкапом
- 💾 Создание и 📦 восстановление резервных копий (включая базу данных)
- 🧹 Очистка логов, бэкапов и образов
- 🌐 Помощник настройки обратного прокси Caddy (webhook + miniapp, обновление `docker-compose`, перезагрузка)
- ⚙️ Конфигуратор `.env` (редактирование, пересоздание, маскировка секретов)
> 💡 Скрипт можно запускать сколько угодно раз — он хранит путь установки и понимает, когда конфигурация уже создана. Меню работает и по SSH (достаточно TTY), а для скриптов можно передать путь установки через stdin.
### 🐳 Ручной Docker запуск
Если не хочется пользоваться мастером, можно настроить всё вручную:
@@ -144,35 +122,67 @@ docker compose logs
---
## 🌐 Настройка обратного прокси и доменов
## 🌐 Настройка webhook-режима и обратного прокси
> Этот раздел описывает полноценную ручную настройку обратного прокси для **двух разных доменов**: отдельный домен для вебхуков (`hooks.example.com`) и отдельный домен для мини-приложения (`miniapp.example.com`). Оба прокси-сервера (Caddy или nginx) должны работать в одной Docker-сети с ботом, чтобы обращаться к сервису по внутреннему имени `remnawave_bot` без проброса портов наружу.
> Встроенный FastAPI-сервер теперь обслуживает Telegram webhook, платежные webhooks, административное API и статические файлы миниапки на **одном порту 8080**. Снаружи вы публикуете только HTTPS-прокси, которое проксирует весь трафик на этот порт.
### 1. Планирование доменов и переменных окружения
### ♻️ Миграция со старой схемы (несколько портов)
1. Добавьте в DNS по **A/AAAA-записи** для обоих доменов на IP сервера, где запущен бот.
2. Убедитесь, что входящий трафик на **80/tcp и 443/tcp** открыт (брандмауэр, облачный фаервол).
3. В `.env` пропишите корректные URL, чтобы бот формировал ссылки с HTTPS-доменами:
```env
WEBHOOK_URL=https://hooks.example.com
WEB_API_ENABLED=true
WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com
MINIAPP_CUSTOM_URL=https://miniapp.example.com
```
Если бот уже развернут в режиме polling или с отдельными контейнерами для `payments-webhook`, выполните переход на единую схему:
### 2. Общая Docker-сеть для бота и прокси
1. **Обновите `.env`:** установите `BOT_RUN_MODE=webhook` (или `both` для гибридного режима), задайте `WEBHOOK_URL`, `WEBHOOK_PATH` и обязательно сгенерируйте собственный `WEBHOOK_SECRET_TOKEN` командой `openssl rand -hex 32`.
2. **Проверьте `docker-compose.*`:** оставьте публикацию только порта `8080` у сервиса `remnawave_bot`. Все значения `*_WEBHOOK_PORT` теперь используются лишь для обратной совместимости и могут быть удалены.
3. **Обновите прокси:** перенаправляйте *все* пути (`/webhook`, `/yookassa-webhook`, `/cryptobot-webhook`, `/miniapp/static`, `/app-config.json` и т.д.) на один upstream `remnawave_bot:8080`. Примеры Caddy/nginx ниже можно адаптировать к текущей инфраструктуре.
4. **Перезапустите сервисы:** `docker compose up -d --force-recreate bot` и затем перезагрузите прокси. После запуска бот автоматически зарегистрирует новый webhook.
5. **Проверьте здоровье:** `curl http://localhost:8080/health/unified` (или `/health`, если административное API отключено) и `curl http://localhost:8080/health/telegram-webhook`. Убедитесь, что в логах нет ошибок регистрации webhook.
`docker-compose.yml` бота создаёт сеть `bot_network`. Чтобы внешний прокси видел сервис `remnawave_bot`, нужно:
После миграции старые контейнеры/сервисы для отдельных вебхуков можно удалить.
### 1. Выбор режима запуска
| `BOT_RUN_MODE` | Что делает | Когда использовать |
|----------------|------------|---------------------|
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
| `both` | Одновременно работают polling и webhook. | Миграция с polling на webhook или повышенная отказоустойчивость. |
### 2. Переменные окружения для webhook
```env
BOT_RUN_MODE=webhook
WEBHOOK_URL=https://bot.example.com
WEBHOOK_PATH=/telegram/webhook
WEBHOOK_SECRET_TOKEN=super-secret-token
WEBHOOK_DROP_PENDING_UPDATES=true
WEBHOOK_MAX_QUEUE_SIZE=1024
WEBHOOK_WORKERS=4
WEBHOOK_ENQUEUE_TIMEOUT=0.1
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
WEB_API_ENABLED=true
WEB_API_HOST=0.0.0.0
WEB_API_PORT=8080
WEB_API_ALLOWED_ORIGINS=https://bot.example.com
MINIAPP_CUSTOM_URL=https://bot.example.com/miniapp
```
* `WEBHOOK_URL` — публичный HTTPS-домен прокси. К нему автоматически добавится путь из `WEBHOOK_PATH`.
* `WEBHOOK_SECRET_TOKEN` — защитный токен Telegram, обязательно задайте своё значение.
* Очередь можно тюнить через `WEBHOOK_MAX_QUEUE_SIZE`, `WEBHOOK_WORKERS`, `WEBHOOK_ENQUEUE_TIMEOUT` и `WEBHOOK_WORKER_SHUTDOWN_TIMEOUT`.
* Если миниапка или админка доступны по другим доменам, перечислите их через запятую в `WEB_API_ALLOWED_ORIGINS`.
После изменения `.env` перезапустите сервис: `docker compose up -d remnawave_bot`.
### 3. Подготовка Docker-сети
`docker-compose.yml` создаёт сеть `bot_network`. Прокси должен находиться в той же сети, чтобы обращаться к контейнеру по имени `remnawave_bot`.
```bash
# Убедиться, что сеть существует
docker network ls | grep bot_network || docker network create bot_network
# Подключить прокси (если контейнер уже запущен отдельно)
docker network connect bot_network <proxy_container_name>
```
Если прокси запускается через **собственный docker-compose**, в файле нужно объявить ту же сеть как внешнюю:
Если прокси стартует отдельным compose-файлом, объявите сеть внешней:
```yaml
networks:
@@ -180,178 +190,240 @@ networks:
external: true
```
### 3. Ручная установка Caddy в Docker
### 4. Проверка здоровья
1. Создайте каталог для конфигурации:
```bash
mkdir -p ~/caddy
cd ~/caddy
```
Статические файлы миниапки автоматически монтируются из каталога `MINIAPP_STATIC_PATH` (по умолчанию `miniapp/`) и доступны по пути `/miniapp/static`.
2. Сохраните docker-compose-файл `docker-compose.caddy.yml`:
```yaml
services:
caddy:
image: caddy:2-alpine
container_name: remnawave_caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
- /root/remnawave-bedolaga-telegram-bot/miniapp:/miniapp:ro
- /root/remnawave-bedolaga-telegram-bot/miniapp/redirect:/miniapp/redirect:ro
networks:
- bot_network
Проверьте, что единый сервер отвечает:
volumes:
caddy_data:
caddy_config:
```bash
curl -s https://bot.example.com/health/unified | jq
```
networks:
bot_network:
external: true
```
Полезные диагностические endpoints:
3. Создайте `Caddyfile` с двумя виртуальными хостами:
```caddy
webhook.domain.com {
handle /tribute-webhook* {
reverse_proxy remnawave_bot:8081
}
handle /cryptobot-webhook* {
reverse_proxy remnawave_bot:8081
}
handle /mulenpay-webhook* {
reverse_proxy remnawave_bot:8081
}
handle /pal24-webhook* {
reverse_proxy remnawave_bot:8084
}
handle /wata-webhook* {
reverse_proxy remnawave_bot:8081
}
handle /yookassa-webhook* {
reverse_proxy remnawave_bot:8082
}
handle /health {
reverse_proxy remnawave_bot:8081/health
}
}
miniapp.domain.com {
encode gzip zstd
root * /miniapp
file_server
@config path /app-config.json
header @config Access-Control-Allow-Origin "*"
reverse_proxy /miniapp/* remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
}
}
```
- `/health/unified` — агрегированный статус (режим бота, очередь Telegram, наличие миниапки и платежей). Когда административное API отключено, тот же статус доступен по `/health`.
- `/health/telegram-webhook` — состояние очереди Telegram webhook.
- `/health/payment-webhooks` — какие платёжные интеграции активированы.
4. Запустите прокси:
```bash
docker compose -f docker-compose.caddy.yml up -d
```
### 5. Swagger и документация
### 4. Ручная настройка nginx в Docker
- Включите `WEB_API_DOCS_ENABLED=true`, если нужно открыть Swagger UI и OpenAPI. После перезапуска сервиса станут доступны эндпоинты `/docs`, `/doc` (редирект для обратной совместимости), `/redoc` и `/openapi.json`.
- Не забудьте проксировать эти пути через внешний HTTPS-прокси вместе с остальными эндпоинтами бота.
- В продакшене держите `WEB_API_DOCS_ENABLED=false`, чтобы документация не была публичной. При необходимости включайте временно или защищайте прокси базовой авторизацией/IP-фильтрацией.
1. Создайте каталог `/opt/nginx-remnawave` и поместите туда `docker-compose.nginx.yml`:
```yaml
services:
nginx:
image: nginx:1.25-alpine
container_name: remnawave_nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/ssl/private:ro
- ./miniapp:/var/www/remnawave-miniapp:ro
networks:
- bot_network
### 6. Пример Caddy-конфига
networks:
bot_network:
external: true
```
```yaml
services:
caddy:
image: caddy:2-alpine
container_name: remnawave_caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- bot_network
2. Пример `nginx.conf`:
```nginx
events {}
volumes:
caddy_data:
caddy_config:
http {
include /etc/nginx/mime.types;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
networks:
bot_network:
external: true
```
upstream remnawave_bot_hooks {
server remnawave_bot:8081;
}
`Caddyfile`:
upstream remnawave_bot_yookassa {
server remnawave_bot:8082;
}
```caddy
# API
api.domain.com {
encode gzip zstd
@config path /app-config.json
header @config Access-Control-Allow-Origin "*"
reverse_proxy localhost:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
upstream remnawave_bot_api {
server remnawave_bot:8080;
}
# Статика для miniapp
podpiska.domain.com {
encode gzip zstd
# API эндпоинты /miniapp/* в приложение
handle /miniapp/* {
reverse_proxy localhost:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
# app-config.json с CORS
handle /app-config.json {
header Access-Control-Allow-Origin "*"
reverse_proxy localhost:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
# Статические файлы (корень и всё остальное)
handle {
root * /var/www/remnawave-miniapp
try_files {path} /index.html
file_server
}
}
```
server {
listen 80;
listen 443 ssl http2;
server_name hooks.example.com;
####
ssl_certificate /etc/ssl/private/hooks.fullchain.pem;
ssl_certificate_key /etc/ssl/private/hooks.privkey.pem;
Либо вместо `remnawave_bot:8080` используйте `localhost:8080`
location = /webhook { proxy_pass http://remnawave_bot_hooks; }
location /tribute-webhook { proxy_pass http://remnawave_bot_hooks; }
location /cryptobot-webhook { proxy_pass http://remnawave_bot_hooks; }
location /mulenpay-webhook { proxy_pass http://remnawave_bot_hooks; }
location /wata-webhook { proxy_pass http://remnawave_bot_hooks; }
location /pal24-webhook { proxy_pass http://remnawave_bot:8084; }
location /yookassa-webhook { proxy_pass http://remnawave_bot_yookassa; }
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
### 6. Пример nginx-конфига
server {
listen 80;
listen 443 ssl http2;
server_name miniapp.example.com;
```yaml
services:
nginx:
image: nginx:1.25-alpine
container_name: remnawave_nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
networks:
- bot_network
ssl_certificate /etc/ssl/private/miniapp.fullchain.pem;
ssl_certificate_key /etc/ssl/private/miniapp.privkey.pem;
networks:
bot_network:
external: true
```
root /var/www/remnawave-miniapp;
index index.html;
`nginx.conf`:
location /miniapp/ {
proxy_pass http://remnawave_bot_api/miniapp/;
}
}
}
```
```nginx
events {}
http {
include /etc/nginx/mime.types;
sendfile on;
upstream remnawave_bot_unified {
server remnawave_bot:8080;
}
# API домен
server {
listen 80;
listen 443 ssl http2;
server_name api.domain.com;
ssl_certificate /etc/ssl/private/api.fullchain.pem;
ssl_certificate_key /etc/ssl/private/api.privkey.pem;
client_max_body_size 32m;
# app-config.json с CORS
location = /app-config.json {
add_header Access-Control-Allow-Origin "*";
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Все остальное
location / {
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_request_buffering off;
}
}
# Miniapp домен (статика + API)
server {
listen 80;
listen 443 ssl http2;
server_name podpiska.domain.com;
ssl_certificate /etc/ssl/private/podpiska.fullchain.pem;
ssl_certificate_key /etc/ssl/private/podpiska.privkey.pem;
client_max_body_size 32m;
# API эндпоинты /miniapp/*
location /miniapp/ {
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_request_buffering off;
}
# app-config.json с CORS
location = /app-config.json {
add_header Access-Control-Allow-Origin "*";
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Статические файлы
location / {
root /var/www/remnawave-miniapp;
try_files $uri $uri/ /index.html;
# Кэширование
expires 1h;
add_header Cache-Control "public, immutable";
}
}
}
```
####
Либо вместо `remnawave_bot:8080` используйте `localhost:8080`
Рекомендации:
- Откройте входящие 80/443 в фаерволе.
- Если используете Cloudflare/анти-DDoS, разрешите методы `POST` и заголовок `X-Telegram-Bot-Api-Secret-Token`.
- После развёртывания перезапустите бот (`make reload`), чтобы он заново зарегистрировал webhook.
---
@@ -362,19 +434,31 @@ networks:
| Настройка | Где взять | Пример |
|-----------|-----------|---------|
| 🤖 **BOT_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` |
| 🔑 **REMNAWAVE_API_KEY** | Твоя Remnawave панель | `eyJhbGciOiJIUzI1...` |
| 🌐 **REMNAWAVE_API_URL** | URL твоей панели | `https://panel.example.com` |
| 🛡️ **REMNAWAVE_SECRET_KEY** | Ключ защиты панели | `secret_name:secret_value` |
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
[Полный список доступных параметров:](remnawave-bedolaga-telegram-bot/blob/main/.env.example)
### 🌐 Интеграция веб-админки
Подробное пошаговое руководство по запуску административного веб-API и подключению внешней панели находится в [docs/web-admin-integration.md](docs/web-admin-integration.md).
### 🤖 Режимы запуска бота
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
- `WEBHOOK_SECRET_TOKEN` — секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token` при работе через вебхуки.
- `WEBHOOK_DROP_PENDING_UPDATES` — управляет очисткой очереди сообщений при установке вебхука.
- `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защищаться от перегрузок.
- `WEBHOOK_WORKERS` — количество фоновых воркеров, параллельно обрабатывающих обновления Telegram.
- `WEBHOOK_ENQUEUE_TIMEOUT` — сколько секунд ждать свободного места в очереди перед отказом (0 — немедленный отказ).
- `WEBHOOK_WORKER_SHUTDOWN_TIMEOUT` — таймаут корректного завершения воркеров при остановке приложения.
### 📱 Telegram Mini App ЛК
Инструкция по развёртыванию мини-приложения, публикации статической страницы и настройке reverse-proxy доступна в [docs/miniapp-setup.md](docs/miniapp-setup.md).
Путь к статическим файлам мини-приложения можно переопределить через переменную `MINIAPP_STATIC_PATH`.
### 📊 Статус серверов в главном меню
| Переменная | Описание | Пример |
@@ -485,211 +569,6 @@ MONITORING_INTERVAL=60
REDIS_URL=redis://redis:6379/0
```
<details>
<summary>🔧 Полная конфигурация .env</summary>
```env
# ===============================================
# 🤖 REMNAWAVE BEDOLAGA BOT CONFIGURATION
# ===============================================
# ===== TELEGRAM BOT =====
BOT_TOKEN=
ADMIN_IDS=
SUPPORT_USERNAME=@support
# Уведомления администраторов
ADMIN_NOTIFICATIONS_ENABLED=true
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890
ADMIN_NOTIFICATIONS_TOPIC_ID=123
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126
# Автоматические отчеты
ADMIN_REPORTS_ENABLED=false
ADMIN_REPORTS_SEND_TIME=10:00
# Обязательная подписка на канал
CHANNEL_SUB_ID=
CHANNEL_IS_REQUIRED_SUB=false
CHANNEL_LINK=
# ===== DATABASE CONFIGURATION =====
DATABASE_MODE=auto
DATABASE_URL=
# PostgreSQL настройки
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=remnawave_bot
POSTGRES_USER=remnawave_user
POSTGRES_PASSWORD=secure_password_123
# Redis
REDIS_URL=redis://redis:6379/0
# ===== REMNAWAVE API =====
REMNAWAVE_API_URL=https://panel.example.com
REMNAWAVE_API_KEY=your_api_key_here
REMNAWAVE_AUTH_TYPE=api_key
REMNAWAVE_SECRET_KEY=
# Автосинхронизация
REMNAWAVE_AUTO_SYNC_ENABLED=true
REMNAWAVE_AUTO_SYNC_TIMES=03:00,15:00
# Шаблон описания пользователя
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
# Шаблон имени пользователя в панели
REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
REMNAWAVE_USER_DELETE_MODE=delete
# ===== ПОДПИСКИ =====
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
DEFAULT_DEVICE_LIMIT=3
MAX_DEVICES_LIMIT=15
# ===== НАСТРОЙКИ ТРАФИКА =====
TRAFFIC_SELECTION_MODE=selectable
FIXED_TRAFFIC_LIMIT_GB=100
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
AVAILABLE_RENEWAL_PERIODS=30,90,180
# ===== ЦЕНЫ (в копейках) =====
BASE_SUBSCRIPTION_PRICE=0
PRICE_14_DAYS=7000
PRICE_30_DAYS=9900
PRICE_60_DAYS=25900
PRICE_90_DAYS=36900
PRICE_180_DAYS=69900
PRICE_360_DAYS=109900
# Скидки для базовых пользователей
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,0:20000:true"
PRICE_PER_DEVICE=5000
DEVICES_SELECTION_ENABLED=true
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
DEVICES_SELECTION_DISABLED_AMOUNT=0
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_PROGRAM_ENABLED=true
REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
REFERRAL_COMMISSION_PERCENT=25
# ===== АВТОПРОДЛЕНИЕ =====
AUTOPAY_WARNING_DAYS=3,1
DEFAULT_AUTOPAY_ENABLED=true
DEFAULT_AUTOPAY_DAYS_BEFORE=3
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
# Telegram Stars
TELEGRAM_STARS_ENABLED=true
TELEGRAM_STARS_RATE_RUB=1.3
# Tribute
TRIBUTE_ENABLED=false
TRIBUTE_API_KEY=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
# YooKassa
YOOKASSA_ENABLED=false
YOOKASSA_SHOP_ID=
YOOKASSA_SECRET_KEY=
YOOKASSA_SBP_ENABLED=false
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
# CryptoBot
CRYPTOBOT_ENABLED=false
CRYPTOBOT_API_TOKEN=
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
# Heleket
HELEKET_ENABLED=false
HELEKET_MERCHANT_ID=
HELEKET_API_KEY=
HELEKET_WEBHOOK_PATH=/heleket-webhook
HELEKET_WEBHOOK_PORT=8086
# MulenPay
MULENPAY_ENABLED=false
MULENPAY_API_KEY=
MULENPAY_SECRET_KEY=
MULENPAY_SHOP_ID=
MULENPAY_WEBHOOK_PATH=/mulenpay-webhook
# PayPalych / Pal24
PAL24_ENABLED=false
PAL24_API_TOKEN=
PAL24_SHOP_ID=
PAL24_WEBHOOK_PATH=/pal24-webhook
PAL24_SBP_BUTTON_VISIBLE=true
PAL24_CARD_BUTTON_VISIBLE=true
# WATA
WATA_ENABLED=false
WATA_TOKEN=
WATA_TERMINAL_ID=
WATA_WEBHOOK_PATH=/wata-webhook
WATA_WEBHOOK_HOST=0.0.0.0
WATA_WEBHOOK_PORT=8085
# ===== ИНТЕРФЕЙС И UX =====
ENABLE_LOGO_MODE=true
LOGO_FILE=vpn_logo.png
MAIN_MENU_MODE=default
HIDE_SUBSCRIPTION_LINK=false
CONNECT_BUTTON_MODE=guide
# ===== МОНИТОРИНГ И УВЕДОМЛЕНИЯ =====
MONITORING_INTERVAL=60
ENABLE_NOTIFICATIONS=true
NOTIFICATION_RETRY_ATTEMPTS=3
# ===== СТАТУС СЕРВЕРОВ =====
SERVER_STATUS_MODE=disabled
SERVER_STATUS_EXTERNAL_URL=
SERVER_STATUS_METRICS_URL=
SERVER_STATUS_ITEMS_PER_PAGE=10
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
MAINTENANCE_MODE=false
MAINTENANCE_CHECK_INTERVAL=30
MAINTENANCE_AUTO_ENABLE=true
MAINTENANCE_MONITORING_ENABLED=true
MAINTENANCE_RETRY_ATTEMPTS=1
# ===== ЛОКАЛИЗАЦИЯ =====
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
LANGUAGE_SELECTION_ENABLED=true
# ===== СИСТЕМА БЕКАПОВ =====
BACKUP_AUTO_ENABLED=true
BACKUP_INTERVAL_HOURS=24
BACKUP_TIME=03:00
BACKUP_MAX_KEEP=7
BACKUP_SEND_ENABLED=true
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
VERSION_CHECK_ENABLED=true
VERSION_CHECK_INTERVAL_HOURS=1
# ===== ЛОГИРОВАНИЕ =====
LOG_LEVEL=INFO
LOG_FILE=logs/bot.log
```
</details>
---
#### ⭐ Функционал
@@ -720,6 +599,7 @@ LOG_FILE=logs/bot.log
- 🔔 Уведомления об истечении и автоконверсия
- 💎 Автовыдача бонусов за кампании и инвайты
- 🛡️ Контроль обязательной подписки на канал (отключает подписку при отписке)
- 💎 Автовыдача сквада из пула выбранных
💰 **Платежи и баланс**
- ⭐ Telegram Stars
@@ -750,7 +630,7 @@ LOG_FILE=logs/bot.log
- 💬 Быстрые ссылки на поддержку
🧩 **Бонусы и промо**
- 🎫 Промокоды на деньги, дни, триал подписку
- 🎫 Промокоды на деньги, дни, триал подписку, промогруппу
- 🎁 **Персональные промо-предложения** от админов
- 💰 **Тестовый доступ к серверам** через промо-акции
- 💸 **Автоматические скидки** при оплате и автопродлении
@@ -759,7 +639,7 @@ LOG_FILE=logs/bot.log
- 🔗 Генерация реферальных ссылок и QR кодов
💎 **Промо-группы и скидки**
- 🏷️ **Система промогрупп** с индивидуальными скидками
- 🏷️ **Система промогрупп** с индивидуальными скидками с приоритетами
- 💰 Скидки на серверы, трафик и устройства
- 📊 **Скидочные уровни за траты** - прозрачная система лояльности
- 📈 Автоматическое повышение уровня при достижении порога
@@ -826,7 +706,6 @@ LOG_FILE=logs/bot.log
- 💬 Автоматические сообщения о задолженностях
🧰 **Обслуживание и DevOps**
- 🛠️ `install_bot.sh` - **интерактивное меню управления**
- 🚧 Ручной и авто-режим техработ
- 🗒️ Просмотр системных логов и health-check
- 🔄 **Автосинхронизация Remnawave** по расписанию и при старте бота
@@ -867,8 +746,9 @@ LOG_FILE=logs/bot.log
- 🧠 **Асинхронная архитектура** - aiogram 3, PostgreSQL/SQLite, Redis и очереди задач
- 🌐 **Мультиязычность** - локализации RU/EN, быстрый выбор языка
- 📦 **Интеграция с Remnawave API** - автоматическое создание пользователей и синхронизация
- 🔄 **Миграция сквадов** - массовый перенос пользователей между серверами
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
- 🧾 **История операций** - хранение всех транзакций и действий для аудита
- 💸 **Сервис автопрвоерки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
### 🌐 Веб-API и мини-приложение
@@ -876,7 +756,7 @@ LOG_FILE=logs/bot.log
- 🔑 **Управление API-ключами** - выпуск, отзыв, реактивация токенов
- 🛰️ **Mini App** - полноценный личный кабинет внутри Telegram
- 💳 **Интегрированные платежи** в Mini App (Stars, Pal24, YooKassa, WATA)
- 🧭 **App Config** - централизованная раздача ссылок на клиенты
- 🧭 **Единый стандартный app-config.json** - централизованная раздача ссылок на клиенты
- 🪝 **Платёжные вебхуки** - встроенные серверы для всех платёжных систем
- 📡 **Мониторинг серверов** - REST-эндпоинты для просмотра нод и статистики
@@ -969,9 +849,9 @@ ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # ID топика для тикет
## 🐛 Устранение неполадок
### 🏥 Health Checks
- **Основной**: `http://localhost:8081/health`
- **YooKassa**: `http://localhost:8082/health`
- **Pal24**: `http://localhost:8084/health`
- **Unified сервер**: `http://localhost:8080/health/unified` (или `/health`, если административное API отключено)
- **Telegram webhook**: `http://localhost:8080/health/telegram-webhook`
- **Платёжные webhooks**: `http://localhost:8080/health/payment-webhooks`
### 🔧 Полезные команды
```bash
@@ -1006,7 +886,7 @@ docker system prune
|----------|-------------|---------|
| **Бот не отвечает** | `docker logs remnawave_bot` | Проверь `BOT_TOKEN` и интернет |
| **Ошибки БД** | `docker compose ps postgres` | Проверь статус PostgreSQL |
| **Webhook не работает** | Проверь порты 8081/8082/8084 | Настрой прокси-сервер |
| **Webhook не работает** | `curl http://localhost:8080/health/telegram-webhook` | Проверь `WEBHOOK_URL`, прокси и секрет |
| **API недоступен** | Проверь логи бота | Проверь `REMNAWAVE_API_URL` |
| **Корзина не сохраняется** | `docker compose ps redis` | Проверь статус Redis |
| **Платежи не проходят** | Проверь webhook'и | Настрой URL в платежных системах |
@@ -1090,7 +970,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
### 📚 **Полезные ресурсы**
- **📖 [Remnawave Docs](https://docs.remna.st)** - документация панели
- **📖 [https://docs.remna.st](https://docs.rw)))** - документация панели
- **🤖 [Telegram Bot API](https://core.telegram.org/bots/api)** - API ботов
- **🐳 [Docker Guide](https://docs.docker.com/get-started/)** - обучение Docker
- **🛡️ [Reverse Proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)** - защита панели
@@ -1181,28 +1061,11 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
### 🚧 **В разработке**
- 🌎 **Веб-панель** - полноценная административная панель
- 📊 **Расширенная аналитика** - больше метрик и графиков
- 🔄 **API для интеграций** - подключение внешних сервисов
- 🎨 **Темы оформления** - кастомизация интерфейса Mini App
### ✅ **Недавно добавлено**
- 💳 **WATA** - оплата банковскими картами
- 🔄 **Автосинхронизация Remnawave** - фоновая синхронизация серверов
- 🛒 **Умная корзина** - сохранение параметров подписки
- 🏗️ **Модульная архитектура** - подписок и платежей
- 🖥️ **Полноценный личный кабинет** в Mini App
- 💎 **Промо-группы и скидочные уровни** - система лояльности
- 🎁 **Персональные промо-предложения** - таргетированные акции
- 📄 **Система управления контентом** - политика, оферта, FAQ
- 🎫 **Система тикетов** - поддержка пользователей
- 📊 **Мониторинг серверов** - интеграция с XrayChecker
- 🛡️ **Защита от блокировок** - антифрод система
- 🎨 **Темы оформления** - Новая тема интерфейса Mini App by https://t.me/arpicme
---
<div align="center">
## 📄 Лицензия
Проект распространяется под лицензией **MIT**
@@ -1246,66 +1109,8 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
---
## 🔄 Быстрые команды
### 📦 Установка и запуск
```bash
# Автоустановка (рекомендуется)
git clone https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot.git
cd remnawave-bedolaga-telegram-bot
chmod +x install_bot.sh
./install_bot.sh
# Ручной запуск
docker compose up -d
docker compose logs -f
```
### 🔄 Обновление
```bash
# Через install_bot.sh (с автобэкапом)
./install_bot.sh
# Выбрать: 4. 🔄 Обновить проект из Git
# Ручное обновление
git pull
docker compose down
docker compose pull
docker compose up -d
```
### 💾 Бэкап и восстановление
```bash
# Создать бэкап через install_bot.sh
./install_bot.sh
# Выбрать: 5. 💾 Создать резервную копию
# Восстановить бэкап
./install_bot.sh
# Выбрать: 6. 📦 Восстановить из бэкапа
```
### 📊 Мониторинг
```bash
# Статус сервисов
docker compose ps
# Логи бота
docker compose logs -f bot
# Проверка здоровья
curl http://localhost:8081/health
# Использование ресурсов
docker stats
```
---
## 📈 Статистика проекта
<div align="center">
![GitHub stars](https://img.shields.io/github/stars/Fr1ngg/remnawave-bedolaga-telegram-bot?style=social)
![GitHub forks](https://img.shields.io/github/forks/Fr1ngg/remnawave-bedolaga-telegram-bot?style=social)
![GitHub watchers](https://img.shields.io/github/watchers/Fr1ngg/remnawave-bedolaga-telegram-bot?style=social)
@@ -1324,8 +1129,6 @@ docker stats
## 🎯 Ключевые особенности в цифрах
<div align="center">
| Метрика | Значение |
|---------|----------|
| 💳 **Платёжных систем** | 8 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, WATA) |
@@ -1337,8 +1140,6 @@ docker stats
| 🛡️ **Методов авторизации** | 4 (API Key, Bearer, Basic Auth, eGames) |
| 🗄️ **Способов хранения** | 2 (PostgreSQL, SQLite) с автовыбором |
</div>
---
## 🔥 Почему выбирают Bedolaga?
+2 -3
View File
@@ -136,8 +136,7 @@ logger.debug(f"Webhook payload: {webhook_data}")
# Открыть только необходимые порты
ufw allow 80/tcp # HTTP (redirect to HTTPS)
ufw allow 443/tcp # HTTPS
ufw deny 8081/tcp # Webhook порты (только через reverse proxy)
ufw deny 8082/tcp
ufw deny 8080/tcp # Unified FastAPI сервер доступен только из внутренней сети
```
### 📊 Мониторинг безопасности
@@ -283,7 +282,7 @@ services:
# Не пробрасывайте порты наружу без необходимости
ports:
- "127.0.0.1:8081:8081" # Только localhost
- "127.0.0.1:8080:8080" # Только localhost
# Read-only root filesystem
read_only: true
+64 -1
View File
@@ -291,6 +291,7 @@ class Settings(BaseSettings):
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
MINIAPP_STATIC_PATH: str = "miniapp"
MINIAPP_PURCHASE_URL: str = ""
MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN"
MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN"
@@ -319,6 +320,13 @@ class Settings(BaseSettings):
DEBUG: bool = False
WEBHOOK_URL: Optional[str] = None
WEBHOOK_PATH: str = "/webhook"
WEBHOOK_SECRET_TOKEN: Optional[str] = None
WEBHOOK_DROP_PENDING_UPDATES: bool = True
WEBHOOK_MAX_QUEUE_SIZE: int = 1024
WEBHOOK_WORKERS: int = 4
WEBHOOK_ENQUEUE_TIMEOUT: float = 0.1
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT: float = 30.0
BOT_RUN_MODE: str = "polling"
WEB_API_ENABLED: bool = False
WEB_API_HOST: str = "0.0.0.0"
@@ -1423,7 +1431,62 @@ class Settings(BaseSettings):
def is_support_contact_enabled(self) -> bool:
return self.get_support_system_mode() in {"contact", "both"}
def get_bot_run_mode(self) -> str:
mode = (self.BOT_RUN_MODE or "polling").strip().lower()
if mode not in {"polling", "webhook", "both"}:
return "polling"
return mode
def get_telegram_webhook_path(self) -> str:
raw_path = (self.WEBHOOK_PATH or "/webhook").strip()
if not raw_path:
raw_path = "/webhook"
if not raw_path.startswith("/"):
raw_path = "/" + raw_path
return raw_path
def get_webhook_queue_maxsize(self) -> int:
try:
size = int(self.WEBHOOK_MAX_QUEUE_SIZE)
except (TypeError, ValueError):
size = 1024
return max(1, size)
def get_webhook_worker_count(self) -> int:
try:
workers = int(self.WEBHOOK_WORKERS)
except (TypeError, ValueError):
workers = 1
return max(1, workers)
def get_webhook_enqueue_timeout(self) -> float:
try:
timeout = float(self.WEBHOOK_ENQUEUE_TIMEOUT)
except (TypeError, ValueError):
timeout = 0.0
return max(0.0, timeout)
def get_webhook_shutdown_timeout(self) -> float:
try:
timeout = float(self.WEBHOOK_WORKER_SHUTDOWN_TIMEOUT)
except (TypeError, ValueError):
timeout = 30.0
return max(1.0, timeout)
def get_telegram_webhook_url(self) -> Optional[str]:
base_url = (self.WEBHOOK_URL or "").strip()
if not base_url:
return None
path = self.get_telegram_webhook_path()
return f"{base_url.rstrip('/')}{path}"
def get_miniapp_static_path(self) -> Path:
raw_path = (self.MINIAPP_STATIC_PATH or "miniapp").strip()
if not raw_path:
raw_path = "miniapp"
return Path(raw_path)
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
+37 -2
View File
@@ -22,6 +22,41 @@ async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCo
return result.scalar_one_or_none()
async def get_promocode_by_id(db: AsyncSession, promo_id: int) -> Optional[PromoCode]:
"""
Получает промокод по ID с eager loading всех связанных данных.
Используется для избежания lazy loading в async контексте.
"""
result = await db.execute(
select(PromoCode)
.options(
selectinload(PromoCode.uses),
selectinload(PromoCode.promo_group)
)
.where(PromoCode.id == promo_id)
)
return result.scalar_one_or_none()
async def check_promocode_validity(db: AsyncSession, code: str) -> dict:
"""
Проверяет существование и валидность промокода без активации.
Возвращает словарь с информацией о промокоде.
"""
promocode = await get_promocode_by_code(db, code)
if not promocode:
return {"valid": False, "error": "not_found", "promocode": None}
if not promocode.is_valid:
if promocode.current_uses >= promocode.max_uses:
return {"valid": False, "error": "used", "promocode": None}
else:
return {"valid": False, "error": "expired", "promocode": None}
return {"valid": True, "error": None, "promocode": promocode}
async def create_promocode(
db: AsyncSession,
code: str,
@@ -61,9 +96,9 @@ async def use_promocode(
promocode_id: int,
user_id: int
) -> bool:
try:
promocode = await db.get(PromoCode, promocode_id)
promocode = await get_promocode_by_id(db, promocode_id)
if not promocode:
return False
+19 -19
View File
@@ -15,7 +15,7 @@ from app.localization.texts import get_texts
from app.database.crud.promocode import (
get_promocodes_list, get_promocodes_count, create_promocode,
get_promocode_statistics, get_promocode_by_code, update_promocode,
delete_promocode
delete_promocode, get_promocode_by_id
)
from app.database.crud.promo_group import get_promo_group_by_id, get_promo_groups_with_counts
from app.utils.decorators import admin_required, error_handler
@@ -138,8 +138,8 @@ async def show_promocode_management(
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -219,8 +219,8 @@ async def show_promocode_edit_menu(
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -648,8 +648,8 @@ async def handle_edit_value(
data = await state.get_data()
promo_id = data.get('editing_promo_id')
edit_action = data.get('edit_action')
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
@@ -734,8 +734,8 @@ async def handle_edit_uses(
):
data = await state.get_data()
promo_id = data.get('editing_promo_id')
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
@@ -873,8 +873,8 @@ async def handle_edit_expiry(
):
data = await state.get_data()
promo_id = data.get('editing_promo_id')
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
@@ -920,8 +920,8 @@ async def toggle_promocode_status(
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -947,8 +947,8 @@ async def confirm_delete_promocode(
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -995,8 +995,8 @@ async def delete_promocode_confirmed(
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -1019,8 +1019,8 @@ async def show_promocode_stats(
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
+47 -26
View File
@@ -1,9 +1,8 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram import Dispatcher, types, F, Bot
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import PromoCodeStates
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
@@ -32,6 +31,45 @@ async def show_promocode_menu(
await callback.answer()
async def activate_promocode_for_registration(
db: AsyncSession,
user_id: int,
code: str,
bot: Bot = None
) -> dict:
"""
Активирует промокод для пользователя во время регистрации.
Возвращает результат активации без отправки сообщений.
"""
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(db, user_id, code)
if result["success"]:
logger.info(f"✅ Пользователь {user_id} активировал промокод {code} при регистрации")
# Отправляем уведомление админу, если бот доступен
if bot:
try:
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if user:
notification_service = AdminNotificationService(bot)
await notification_service.send_promocode_activation_notification(
db,
user,
result.get("promocode", {"code": code}),
result["description"],
)
except Exception as notify_error:
logger.error(
"Ошибка отправки админ уведомления об активации промокода %s: %s",
code,
notify_error,
)
return result
@error_handler
async def process_promocode(
message: types.Message,
@@ -40,9 +78,9 @@ async def process_promocode(
db: AsyncSession
):
texts = get_texts(db_user.language)
code = message.text.strip()
if not code:
await message.answer(
texts.t(
@@ -52,31 +90,14 @@ async def process_promocode(
reply_markup=get_back_keyboard(db_user.language)
)
return
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(db, db_user.id, code)
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
if result["success"]:
await message.answer(
texts.PROMOCODE_SUCCESS.format(description=result["description"]),
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
try:
notification_service = AdminNotificationService(message.bot)
await notification_service.send_promocode_activation_notification(
db,
db_user,
result.get("promocode", {"code": code}),
result["description"],
)
except Exception as notify_error:
logger.error(
"Ошибка отправки админ уведомления об активации промокода %s: %s",
code,
notify_error,
)
else:
error_messages = {
"not_found": texts.PROMOCODE_INVALID,
@@ -85,13 +106,13 @@ async def process_promocode(
"already_used_by_user": texts.PROMOCODE_USED,
"server_error": texts.ERROR
}
error_text = error_messages.get(result["error"], texts.PROMOCODE_INVALID)
await message.answer(
error_text,
reply_markup=get_back_keyboard(db_user.language)
)
await state.clear()
+127 -42
View File
@@ -100,15 +100,15 @@ async def handle_potential_referral_code(
db: AsyncSession
):
current_state = await state.get_state()
logger.info(f"🔍 REFERRAL CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}")
logger.info(f"🔍 REFERRAL/PROMO CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}")
if current_state not in [
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_referral_code.state,
None
None
]:
return False
user = await get_user_by_telegram_id(db, message.from_user.id)
if user and user.status == UserStatus.ACTIVE.value:
return False
@@ -125,37 +125,73 @@ async def handle_potential_referral_code(
if len(potential_code) < 4 or len(potential_code) > 20:
return False
# Сначала проверяем реферальный код
referrer = await get_user_by_referral_code(db, potential_code)
if not referrer:
await message.answer(texts.t(
"REFERRAL_CODE_INVALID_HELP",
"❌ Неверный реферальный код.\n\n"
"💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n"
"⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
))
if referrer:
data['referral_code'] = potential_code
data['referrer_id'] = referrer.id
await state.set_data(data)
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "Реферальный код принят!"))
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
if current_state != RegistrationStates.waiting_for_referral_code.state:
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
rules_text = await get_rules(language)
await message.answer(
rules_text,
reply_markup=get_rules_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info("📋 Правила отправлены после ввода реферального кода")
else:
await complete_registration(message, state, db)
return True
data['referral_code'] = potential_code
data['referrer_id'] = referrer.id
await state.set_data(data)
# Если реферальный код не найден, проверяем промокод
from app.database.crud.promocode import check_promocode_validity
promocode_check = await check_promocode_validity(db, potential_code)
if promocode_check["valid"]:
# Промокод валиден - сохраняем его в state для активации после создания пользователя
data['promocode'] = potential_code
await state.set_data(data)
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
if current_state != RegistrationStates.waiting_for_referral_code.state:
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
rules_text = await get_rules(language)
await message.answer(
rules_text,
reply_markup=get_rules_keyboard(language)
texts.t(
"PROMOCODE_ACCEPTED_WILL_ACTIVATE",
"✅ Промокод принят! Он будет активирован после завершения регистрации."
)
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info("📋 Правила отправлены после ввода реферального кода")
else:
await complete_registration(message, state, db)
logger.info(f"✅ Промокод {potential_code} сохранен для активации для пользователя {message.from_user.id}")
if current_state != RegistrationStates.waiting_for_referral_code.state:
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
rules_text = await get_rules(language)
await message.answer(
rules_text,
reply_markup=get_rules_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info("📋 Правила отправлены после принятия промокода")
else:
await complete_registration(message, state, db)
return True
# Ни реферальный код, ни промокод не найдены
await message.answer(texts.t(
"REFERRAL_OR_PROMO_CODE_INVALID_HELP",
"❌ Неверный реферальный код или промокод.\n\n"
"💡 Если у вас есть реферальный код или промокод, убедитесь что он введен правильно.\n"
"⏭️ Для продолжения регистрации без кода используйте команду /start",
))
return True
@@ -692,31 +728,57 @@ async def process_rules_accept(
async def process_referral_code_input(
message: types.Message,
state: FSMContext,
message: types.Message,
state: FSMContext,
db: AsyncSession
):
logger.info(f"🎫 REFERRAL: Обработка реферального кода: {message.text}")
logger.info(f"🎫 REFERRAL/PROMO: Обработка кода: {message.text}")
data = await state.get_data() or {}
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
referral_code = message.text.strip()
code = message.text.strip()
referrer = await get_user_by_referral_code(db, referral_code)
# Сначала проверяем, является ли это реферальным кодом
referrer = await get_user_by_referral_code(db, code)
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
logger.info(f"✅ Реферальный код применен")
else:
await message.answer(texts.t("REFERRAL_CODE_INVALID", "❌ Неверный реферальный код"))
logger.info(f"❌ Неверный реферальный код")
logger.info(f"✅ Реферальный код применен: {code}")
await complete_registration(message, state, db)
return
await complete_registration(message, state, db)
# Если реферальный код не найден, проверяем промокод
from app.database.crud.promocode import check_promocode_validity
promocode_check = await check_promocode_validity(db, code)
if promocode_check["valid"]:
# Промокод валиден - сохраняем его в state для активации после создания пользователя
data['promocode'] = code
await state.set_data(data)
await message.answer(
texts.t(
"PROMOCODE_ACCEPTED_WILL_ACTIVATE",
"✅ Промокод принят! Он будет активирован после завершения регистрации."
)
)
logger.info(f"✅ Промокод сохранен для активации: {code}")
await complete_registration(message, state, db)
return
# Ни реферальный код, ни промокод не найдены
await message.answer(
texts.t(
"REFERRAL_OR_PROMO_CODE_INVALID",
"❌ Неверный реферальный код или промокод"
)
)
logger.info(f"❌ Неверный код (ни реферальный, ни промокод): {code}")
return
async def process_referral_code_skip(
@@ -1157,6 +1219,29 @@ async def complete_registration(
except Exception as e:
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
# Активируем промокод если был сохранен в state
promocode_to_activate = data.get('promocode')
if promocode_to_activate:
try:
from app.handlers.promocode import activate_promocode_for_registration
promocode_result = await activate_promocode_for_registration(
db, user.id, promocode_to_activate, message.bot
)
if promocode_result["success"]:
await message.answer(
texts.t(
"PROMOCODE_ACTIVATED_AT_REGISTRATION",
"✅ Промокод активирован!\n\n{description}"
).format(description=promocode_result["description"])
)
logger.info(f"✅ Промокод {promocode_to_activate} активирован для пользователя {user.id}")
else:
logger.warning(f"⚠️ Не удалось активировать промокод {promocode_to_activate}: {promocode_result.get('error')}")
except Exception as e:
logger.error(f"❌ Ошибка при активации промокода {promocode_to_activate}: {e}")
campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts)
try:
+5 -1
View File
@@ -1133,7 +1133,11 @@
"REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.",
"REFERRAL_CODE_INVALID": "❌ Invalid referral code",
"REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a friend's referral code?</b>\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a referral code or promo code?</b>\n\nIf you have a promo code or referral link from a friend, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
"REFERRAL_OR_PROMO_CODE_INVALID": "❌ Invalid referral code or promo code",
"REFERRAL_OR_PROMO_CODE_INVALID_HELP": "❌ Invalid referral code or promo code.\n\n💡 If you have a referral code or promo code, please double-check the spelling.\n⏭️ To continue registration without a code, use the /start command.",
"PROMOCODE_ACTIVATED_AT_REGISTRATION": "✅ Promo code activated!\n\n{description}",
"PROMOCODE_ACCEPTED_WILL_ACTIVATE": "✅ Promo code accepted! It will be activated after registration is complete.",
"REFERRAL_CODE_SKIP": "⏭️ Skip",
"REFERRAL_CODE_TITLE": "🆔 <b>Your code:</b> <code>{code}</code>",
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Earnings by type:</b>",
+5 -1
View File
@@ -1153,7 +1153,11 @@
"REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.",
"REFERRAL_CODE_INVALID": "❌ Неверный реферальный код",
"REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код от друга?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код или промокод?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
"REFERRAL_OR_PROMO_CODE_INVALID": "❌ Неверный реферальный код или промокод",
"REFERRAL_OR_PROMO_CODE_INVALID_HELP": "❌ Неверный реферальный код или промокод.\n\n💡 Если у вас есть реферальный код или промокод, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без кода используйте команду /start",
"PROMOCODE_ACTIVATED_AT_REGISTRATION": "✅ Промокод активирован!\n\n{description}",
"PROMOCODE_ACCEPTED_WILL_ACTIVATE": "✅ Промокод принят! Он будет активирован после завершения регистрации.",
"REFERRAL_CODE_SKIP": "⏭️ Пропустить",
"REFERRAL_CODE_TITLE": "🆔 <b>Ваш код:</b> <code>{code}</code>",
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Доходы по типам:</b>",
+15 -2
View File
@@ -42,7 +42,15 @@ from app.utils.timezone import format_local_datetime
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
from app.database.models import (
MonitoringLog,
SubscriptionStatus,
Subscription,
User,
Ticket,
TicketStatus,
UserPromoGroup,
)
from app.localization.texts import get_texts
from app.services.notification_settings_service import NotificationSettingsService
from app.services.payment_service import PaymentService
@@ -386,7 +394,12 @@ class MonitoringService:
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.options(
selectinload(Subscription.user).selectinload(User.promo_group),
selectinload(Subscription.user)
.selectinload(User.user_promo_groups)
.selectinload(UserPromoGroup.promo_group),
)
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
+4 -3
View File
@@ -10,6 +10,7 @@ from app.database.crud.promocode import (
create_promocode,
delete_promocode,
get_promocode_by_code,
get_promocode_by_id,
get_promocode_statistics,
get_promocodes_count,
get_promocodes_list,
@@ -163,7 +164,7 @@ async def get_promocode(
_: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PromoCodeDetailResponse:
promocode = await db.get(PromoCode, promocode_id)
promocode = await get_promocode_by_id(db, promocode_id)
if not promocode:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found")
@@ -236,7 +237,7 @@ async def update_promocode_endpoint(
_: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PromoCodeResponse:
promocode = await db.get(PromoCode, promocode_id)
promocode = await get_promocode_by_id(db, promocode_id)
if not promocode:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found")
@@ -290,7 +291,7 @@ async def delete_promocode_endpoint(
_: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Response:
promocode = await db.get(PromoCode, promocode_id)
promocode = await get_promocode_by_id(db, promocode_id)
if not promocode:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found")
+3 -2
View File
@@ -17,8 +17,8 @@ logger = logging.getLogger(__name__)
class WebAPIServer:
"""Асинхронный uvicorn-сервер для административного API."""
def __init__(self) -> None:
self._app = create_web_api_app()
def __init__(self, app: Optional[object] = None) -> None:
self._app = app or create_web_api_app()
workers = max(1, int(settings.WEB_API_WORKERS or 1))
if workers > 1:
@@ -46,6 +46,7 @@ class WebAPIServer:
await self._server.serve()
except Exception as error: # pragma: no cover - логируем ошибки сервера
logger.exception("❌ Ошибка работы веб-API: %s", error)
raise
logger.info(
"🌐 Запуск административного API на %s:%s",
+11
View File
@@ -0,0 +1,11 @@
from typing import Any
__all__ = ["create_unified_app"]
def __getattr__(name: str) -> Any:
if name == "create_unified_app":
from .unified_app import create_unified_app as _create_unified_app
return _create_unified_app
raise AttributeError(name)
+596
View File
@@ -0,0 +1,596 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
from typing import Iterable
from fastapi import APIRouter, Request, Response, status
from fastapi.responses import JSONResponse
from aiogram import Bot
from app.config import settings
from app.database.database import get_db
from app.external.tribute import TributeService as TributeAPI
from app.external.yookassa_webhook import YooKassaWebhookHandler
from app.external.wata_webhook import WataWebhookHandler
from app.external.heleket_webhook import HeleketWebhookHandler
from app.external.pal24_client import Pal24APIError
from app.services.pal24_service import Pal24Service
from app.services.payment_service import PaymentService
from app.services.tribute_service import TributeService
logger = logging.getLogger(__name__)
def _create_cors_response() -> Response:
return Response(
status_code=status.HTTP_200_OK,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, Authorization",
},
)
def _extract_header(request: Request, header_names: Iterable[str]) -> str | None:
for header_name in header_names:
value = request.headers.get(header_name)
if value:
return value.strip()
return None
def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool:
secret_key = settings.MULENPAY_SECRET_KEY
display_name = settings.get_mulenpay_display_name()
if not secret_key:
logger.error("%s secret key is not configured", display_name)
return False
signature = _extract_header(
request,
(
"X-MulenPay-Signature",
"X-Mulenpay-Signature",
"X-MULENPAY-SIGNATURE",
"X-MulenPay-Webhook-Signature",
"X-Mulenpay-Webhook-Signature",
"X-MULENPAY-WEBHOOK-SIGNATURE",
"X-Signature",
"Signature",
"X-MulenPay-Sign",
"X-Mulenpay-Sign",
"X-MULENPAY-SIGN",
"MulenPay-Signature",
"Mulenpay-Signature",
"MULENPAY-SIGNATURE",
"signature",
"sign",
),
)
if signature:
normalized_signature = signature
if normalized_signature.lower().startswith("sha256="):
normalized_signature = normalized_signature.split("=", 1)[1].strip()
hmac_digest = hmac.new(secret_key.encode("utf-8"), raw_body, hashlib.sha256).digest()
expected_hex = hmac_digest.hex()
expected_base64 = base64.b64encode(hmac_digest).decode("utf-8").strip()
expected_urlsafe = base64.urlsafe_b64encode(hmac_digest).decode("utf-8").strip()
normalized_lower = normalized_signature.lower()
if hmac.compare_digest(normalized_lower, expected_hex.lower()):
return True
normalized_no_padding = normalized_signature.rstrip("=")
if hmac.compare_digest(normalized_no_padding, expected_base64.rstrip("=")):
return True
if hmac.compare_digest(normalized_no_padding, expected_urlsafe.rstrip("=")):
return True
logger.error("Неверная подпись %s webhook", display_name)
return False
authorization_header = request.headers.get("Authorization")
if authorization_header:
scheme, _, value = authorization_header.partition(" ")
scheme_lower = scheme.lower()
token = value.strip() if value else scheme.strip()
if scheme_lower in {"bearer", "token"}:
if hmac.compare_digest(token, secret_key):
return True
logger.error("Неверный %s токен %s webhook", scheme, display_name)
return False
if not value and hmac.compare_digest(token, secret_key):
return True
fallback_token = _extract_header(
request,
(
"X-MulenPay-Token",
"X-Mulenpay-Token",
"X-Webhook-Token",
),
)
if fallback_token and hmac.compare_digest(fallback_token, secret_key):
return True
logger.error("Отсутствует подпись %s webhook", display_name)
return False
async def _process_payment_service_callback(
payment_service: PaymentService,
payload: dict,
method_name: str,
) -> bool:
db_generator = get_db()
try:
db = await db_generator.__anext__()
except StopAsyncIteration: # pragma: no cover - defensive guard
return False
try:
process_callback = getattr(payment_service, method_name)
return await process_callback(db, payload)
finally:
try:
await db_generator.__anext__()
except StopAsyncIteration:
pass
async def _parse_pal24_payload(request: Request) -> dict[str, str]:
try:
if request.headers.get("content-type", "").startswith("application/json"):
data = await request.json()
if isinstance(data, dict):
return {str(k): str(v) for k, v in data.items()}
except json.JSONDecodeError:
logger.debug("Pal24 webhook JSON payload не удалось распарсить")
form = await request.form()
if form:
return {str(k): str(v) for k, v in form.multi_items()}
raw_body = (await request.body()).decode("utf-8")
if raw_body:
try:
data = json.loads(raw_body)
if isinstance(data, dict):
return {str(k): str(v) for k, v in data.items()}
except json.JSONDecodeError:
logger.debug("Pal24 webhook body не удалось распарсить как JSON: %s", raw_body)
return {}
def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRouter | None:
router = APIRouter()
routes_registered = False
if settings.TRIBUTE_ENABLED:
tribute_service = TributeService(bot)
tribute_api = TributeAPI()
@router.options(settings.TRIBUTE_WEBHOOK_PATH)
async def tribute_options() -> Response:
return _create_cors_response()
@router.post(settings.TRIBUTE_WEBHOOK_PATH)
async def tribute_webhook(request: Request) -> JSONResponse:
raw_body = await request.body()
if not raw_body:
return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST)
payload = raw_body.decode("utf-8")
signature = request.headers.get("trbt-signature")
if not signature:
return JSONResponse(
{"status": "error", "reason": "missing_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
if settings.TRIBUTE_API_KEY and not tribute_api.verify_webhook_signature(payload, signature):
return JSONResponse(
{"status": "error", "reason": "invalid_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
try:
json.loads(payload)
except json.JSONDecodeError:
return JSONResponse(
{"status": "error", "reason": "invalid_json"},
status_code=status.HTTP_400_BAD_REQUEST,
)
result = await tribute_service.process_webhook(payload)
if result:
return JSONResponse({"status": "ok", "result": result})
return JSONResponse(
{"status": "error", "reason": "processing_failed"},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
if settings.is_mulenpay_enabled():
@router.options(settings.MULENPAY_WEBHOOK_PATH)
async def mulenpay_options() -> Response:
return _create_cors_response()
@router.post(settings.MULENPAY_WEBHOOK_PATH)
async def mulenpay_webhook(request: Request) -> JSONResponse:
raw_body = await request.body()
if not raw_body:
return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST)
if not _verify_mulenpay_signature(request, raw_body):
return JSONResponse(
{"status": "error", "reason": "invalid_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
try:
payload = json.loads(raw_body.decode("utf-8"))
except json.JSONDecodeError:
return JSONResponse(
{"status": "error", "reason": "invalid_json"},
status_code=status.HTTP_400_BAD_REQUEST,
)
success = await _process_payment_service_callback(
payment_service,
payload,
"process_mulenpay_callback",
)
if success:
return JSONResponse({"status": "ok"})
return JSONResponse(
{"status": "error", "reason": "processing_failed"},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
if settings.is_cryptobot_enabled():
@router.options(settings.CRYPTOBOT_WEBHOOK_PATH)
async def cryptobot_options() -> Response:
return _create_cors_response()
@router.post(settings.CRYPTOBOT_WEBHOOK_PATH)
async def cryptobot_webhook(request: Request) -> JSONResponse:
raw_body = await request.body()
if not raw_body:
return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST)
payload_text = raw_body.decode("utf-8")
try:
payload = json.loads(payload_text)
except json.JSONDecodeError:
return JSONResponse(
{"status": "error", "reason": "invalid_json"},
status_code=status.HTTP_400_BAD_REQUEST,
)
signature = request.headers.get("Crypto-Pay-API-Signature")
secret = settings.CRYPTOBOT_WEBHOOK_SECRET
if secret:
if not signature:
return JSONResponse(
{"status": "error", "reason": "missing_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
from app.external.cryptobot import CryptoBotService
if not CryptoBotService().verify_webhook_signature(payload_text, signature):
return JSONResponse(
{"status": "error", "reason": "invalid_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
success = await _process_payment_service_callback(
payment_service,
payload,
"process_cryptobot_webhook",
)
if success:
return JSONResponse({"status": "ok"})
return JSONResponse(
{"status": "error", "reason": "processing_failed"},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
if settings.is_yookassa_enabled():
yookassa_secret = settings.YOOKASSA_WEBHOOK_SECRET or ""
@router.options(settings.YOOKASSA_WEBHOOK_PATH)
async def yookassa_options() -> Response:
return Response(
status_code=status.HTTP_200_OK,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-YooKassa-Signature, Signature",
},
)
@router.get(settings.YOOKASSA_WEBHOOK_PATH)
async def yookassa_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"service": "yookassa_webhook",
"enabled": settings.is_yookassa_enabled(),
}
)
@router.post(settings.YOOKASSA_WEBHOOK_PATH)
async def yookassa_webhook(request: Request) -> JSONResponse:
body_bytes = await request.body()
if not body_bytes:
return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST)
body = body_bytes.decode("utf-8")
signature = request.headers.get("Signature") or request.headers.get("X-YooKassa-Signature")
if yookassa_secret:
if not signature:
return JSONResponse(
{"status": "error", "reason": "missing_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, yookassa_secret):
return JSONResponse(
{"status": "error", "reason": "invalid_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
try:
webhook_data = json.loads(body)
except json.JSONDecodeError:
return JSONResponse(
{"status": "error", "reason": "invalid_json"},
status_code=status.HTTP_400_BAD_REQUEST,
)
event_type = webhook_data.get("event")
if not event_type:
return JSONResponse(
{"status": "error", "reason": "missing_event"},
status_code=status.HTTP_400_BAD_REQUEST,
)
if event_type not in {"payment.succeeded", "payment.waiting_for_capture"}:
return JSONResponse({"status": "ok", "ignored": event_type})
success = await _process_payment_service_callback(
payment_service,
webhook_data,
"process_yookassa_webhook",
)
if success:
return JSONResponse({"status": "ok"})
return JSONResponse(
{"status": "error", "reason": "processing_failed"},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
routes_registered = True
if settings.is_wata_enabled():
wata_handler = WataWebhookHandler(payment_service)
@router.options(settings.WATA_WEBHOOK_PATH)
async def wata_options() -> Response:
return Response(
status_code=status.HTTP_200_OK,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Signature",
},
)
@router.get(settings.WATA_WEBHOOK_PATH)
async def wata_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"service": "wata_webhook",
"enabled": settings.is_wata_enabled(),
}
)
@router.post(settings.WATA_WEBHOOK_PATH)
async def wata_webhook(request: Request) -> JSONResponse:
raw_body = await request.body()
if not raw_body:
return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST)
signature = request.headers.get("X-Signature") or ""
if not await wata_handler._verify_signature(raw_body.decode("utf-8"), signature): # type: ignore[attr-defined]
return JSONResponse(
{"status": "error", "reason": "invalid_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
try:
payload = json.loads(raw_body.decode("utf-8"))
except json.JSONDecodeError:
return JSONResponse(
{"status": "error", "reason": "invalid_json"},
status_code=status.HTTP_400_BAD_REQUEST,
)
success = await _process_payment_service_callback(
payment_service,
payload,
"process_wata_webhook",
)
if success:
return JSONResponse({"status": "ok"})
return JSONResponse(
{"status": "error", "reason": "not_processed"},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
if settings.is_heleket_enabled():
heleket_handler = HeleketWebhookHandler(payment_service)
@router.options(settings.HELEKET_WEBHOOK_PATH)
async def heleket_options() -> Response:
return Response(
status_code=status.HTTP_200_OK,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
)
@router.get(settings.HELEKET_WEBHOOK_PATH)
async def heleket_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"service": "heleket_webhook",
"enabled": settings.is_heleket_enabled(),
}
)
@router.post(settings.HELEKET_WEBHOOK_PATH)
async def heleket_webhook(request: Request) -> JSONResponse:
try:
payload = await request.json()
except json.JSONDecodeError:
return JSONResponse(
{"status": "error", "reason": "invalid_json"},
status_code=status.HTTP_400_BAD_REQUEST,
)
if not heleket_handler.service.verify_webhook_signature(payload):
return JSONResponse(
{"status": "error", "reason": "invalid_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
success = await _process_payment_service_callback(
payment_service,
payload,
"process_heleket_webhook",
)
if success:
return JSONResponse({"status": "ok"})
return JSONResponse(
{"status": "error", "reason": "not_processed"},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
if settings.is_pal24_enabled():
pal24_service = Pal24Service()
@router.options(settings.PAL24_WEBHOOK_PATH)
async def pal24_options() -> Response:
return Response(
status_code=status.HTTP_200_OK,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
)
@router.get(settings.PAL24_WEBHOOK_PATH)
async def pal24_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"service": "pal24_webhook",
"enabled": settings.is_pal24_enabled(),
}
)
@router.post(settings.PAL24_WEBHOOK_PATH)
async def pal24_webhook(request: Request) -> JSONResponse:
if not pal24_service.is_configured:
return JSONResponse(
{"status": "error", "reason": "service_not_configured"},
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
payload = await _parse_pal24_payload(request)
if not payload:
return JSONResponse(
{"status": "error", "reason": "empty_payload"},
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
parsed_payload = pal24_service.parse_postback(payload)
except Pal24APIError as error:
return JSONResponse(
{"status": "error", "reason": str(error)},
status_code=status.HTTP_400_BAD_REQUEST,
)
success = await _process_payment_service_callback(
payment_service,
parsed_payload,
"process_pal24_postback",
)
if success:
return JSONResponse({"status": "ok"})
return JSONResponse(
{"status": "error", "reason": "not_processed"},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
if routes_registered:
@router.get("/health/payment-webhooks")
async def payment_webhooks_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"tribute_enabled": settings.TRIBUTE_ENABLED,
"mulenpay_enabled": settings.is_mulenpay_enabled(),
"cryptobot_enabled": settings.is_cryptobot_enabled(),
"yookassa_enabled": settings.is_yookassa_enabled(),
"wata_enabled": settings.is_wata_enabled(),
"heleket_enabled": settings.is_heleket_enabled(),
"pal24_enabled": settings.is_pal24_enabled(),
}
)
return router if routes_registered else None
+250
View File
@@ -0,0 +1,250 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import JSONResponse
from aiogram import Bot, Dispatcher
from aiogram.types import Update
from app.config import settings
logger = logging.getLogger(__name__)
class TelegramWebhookProcessorError(RuntimeError):
"""Базовое исключение очереди Telegram webhook."""
class TelegramWebhookProcessorNotRunningError(TelegramWebhookProcessorError):
"""Очередь ещё не запущена или уже остановлена."""
class TelegramWebhookOverloadedError(TelegramWebhookProcessorError):
"""Очередь переполнена и не успевает обрабатывать новые обновления."""
class TelegramWebhookProcessor:
"""Асинхронная очередь обработки Telegram webhook-ов."""
def __init__(
self,
*,
bot: Bot,
dispatcher: Dispatcher,
queue_maxsize: int,
worker_count: int,
enqueue_timeout: float,
shutdown_timeout: float,
) -> None:
self._bot = bot
self._dispatcher = dispatcher
self._queue_maxsize = max(1, queue_maxsize)
self._worker_count = max(0, worker_count)
self._enqueue_timeout = max(0.0, enqueue_timeout)
self._shutdown_timeout = max(1.0, shutdown_timeout)
self._queue: asyncio.Queue[Update | object] = asyncio.Queue(maxsize=self._queue_maxsize)
self._workers: list[asyncio.Task[None]] = []
self._running = False
self._stop_sentinel: object = object()
self._lifecycle_lock = asyncio.Lock()
@property
def is_running(self) -> bool:
return self._running
async def start(self) -> None:
async with self._lifecycle_lock:
if self._running:
return
self._running = True
self._queue = asyncio.Queue(maxsize=self._queue_maxsize)
self._workers.clear()
for index in range(self._worker_count):
task = asyncio.create_task(
self._worker_loop(index),
name=f"telegram-webhook-worker-{index}",
)
self._workers.append(task)
if self._worker_count:
logger.info(
"🚀 Telegram webhook processor запущен: %s воркеров, очередь %s",
self._worker_count,
self._queue_maxsize,
)
else:
logger.warning(
"Telegram webhook processor запущен без воркеров — обновления не будут обрабатываться"
)
async def stop(self) -> None:
async with self._lifecycle_lock:
if not self._running:
return
self._running = False
if self._worker_count > 0:
try:
await asyncio.wait_for(self._queue.join(), timeout=self._shutdown_timeout)
except asyncio.TimeoutError:
logger.warning(
"⏱️ Не удалось дождаться завершения очереди Telegram webhook за %s секунд",
self._shutdown_timeout,
)
else:
drained = 0
while not self._queue.empty():
try:
self._queue.get_nowait()
except asyncio.QueueEmpty: # pragma: no cover - гонка состояния
break
else:
drained += 1
self._queue.task_done()
if drained:
logger.warning(
"Очередь Telegram webhook остановлена без воркеров, потеряно %s обновлений",
drained,
)
for _ in range(len(self._workers)):
try:
self._queue.put_nowait(self._stop_sentinel)
except asyncio.QueueFull:
# Очередь переполнена, подождём пока освободится место
await self._queue.put(self._stop_sentinel)
if self._workers:
await asyncio.gather(*self._workers, return_exceptions=True)
self._workers.clear()
logger.info("🛑 Telegram webhook processor остановлен")
async def enqueue(self, update: Update) -> None:
if not self._running:
raise TelegramWebhookProcessorNotRunningError
try:
if self._enqueue_timeout <= 0:
self._queue.put_nowait(update)
else:
await asyncio.wait_for(self._queue.put(update), timeout=self._enqueue_timeout)
except asyncio.QueueFull as error: # pragma: no cover - защитный сценарий
raise TelegramWebhookOverloadedError from error
except asyncio.TimeoutError as error:
raise TelegramWebhookOverloadedError from error
async def wait_until_drained(self, timeout: float | None = None) -> None:
if not self._running or self._worker_count == 0:
return
if timeout is None:
await self._queue.join()
return
await asyncio.wait_for(self._queue.join(), timeout=timeout)
async def _worker_loop(self, worker_id: int) -> None:
try:
while True:
try:
item = await self._queue.get()
except asyncio.CancelledError: # pragma: no cover - остановка приложения
logger.debug("Worker %s cancelled", worker_id)
raise
if item is self._stop_sentinel:
self._queue.task_done()
break
update = item
try:
await self._dispatcher.feed_update(self._bot, update) # type: ignore[arg-type]
except asyncio.CancelledError: # pragma: no cover - остановка приложения
logger.debug("Worker %s cancelled during processing", worker_id)
raise
except Exception as error: # pragma: no cover - логируем сбой обработчика
logger.exception("Ошибка обработки Telegram update в worker %s: %s", worker_id, error)
finally:
self._queue.task_done()
finally:
logger.debug("Worker %s завершён", worker_id)
async def _dispatch_update(
update: Update,
*,
dispatcher: Dispatcher,
bot: Bot,
processor: TelegramWebhookProcessor | None,
) -> None:
if processor is not None:
try:
await processor.enqueue(update)
except TelegramWebhookOverloadedError as error:
logger.warning("Очередь Telegram webhook переполнена: %s", error)
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="webhook_queue_full") from error
except TelegramWebhookProcessorNotRunningError as error:
logger.error("Telegram webhook processor неактивен: %s", error)
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="webhook_processor_unavailable") from error
return
await dispatcher.feed_update(bot, update)
def create_telegram_router(
bot: Bot,
dispatcher: Dispatcher,
*,
processor: TelegramWebhookProcessor | None = None,
) -> APIRouter:
router = APIRouter()
webhook_path = settings.get_telegram_webhook_path()
secret_token = settings.WEBHOOK_SECRET_TOKEN
@router.post(webhook_path)
async def telegram_webhook(request: Request) -> JSONResponse:
if secret_token:
header_token = request.headers.get("X-Telegram-Bot-Api-Secret-Token")
if header_token != secret_token:
logger.warning("Получен Telegram webhook с неверным секретом")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_secret_token")
content_type = request.headers.get("content-type", "")
if content_type and "application/json" not in content_type.lower():
raise HTTPException(status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="invalid_content_type")
try:
payload: Any = await request.json()
except Exception as error: # pragma: no cover - defensive logging
logger.error("Ошибка чтения Telegram webhook: %s", error)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_payload") from error
try:
update = Update.model_validate(payload)
except Exception as error: # pragma: no cover - defensive logging
logger.error("Ошибка валидации Telegram update: %s", error)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_update") from error
await _dispatch_update(update, dispatcher=dispatcher, bot=bot, processor=processor)
return JSONResponse({"status": "ok"})
@router.get("/health/telegram-webhook")
async def telegram_webhook_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"mode": settings.get_bot_run_mode(),
"path": webhook_path,
"webhook_configured": bool(settings.get_telegram_webhook_url()),
"queue_maxsize": settings.get_webhook_queue_maxsize(),
"workers": settings.get_webhook_worker_count(),
}
)
return router
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
import logging
from pathlib import Path
from fastapi import FastAPI, status
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from aiogram import Bot
from aiogram import Dispatcher
from app.config import settings
from app.services.payment_service import PaymentService
from app.webapi.app import create_web_api_app
from . import payments
from . import telegram
logger = logging.getLogger(__name__)
def _attach_docs_alias(app: FastAPI, docs_url: str | None) -> None:
if not docs_url:
return
alias_path = "/doc"
if alias_path == docs_url:
return
for route in app.router.routes:
if getattr(route, "path", None) == alias_path:
return
target_url = docs_url
@app.get(alias_path, include_in_schema=False)
async def redirect_doc() -> RedirectResponse: # pragma: no cover - simple redirect
return RedirectResponse(url=target_url, status_code=status.HTTP_307_TEMPORARY_REDIRECT)
def _create_base_app() -> FastAPI:
docs_config = settings.get_web_api_docs_config()
if settings.is_web_api_enabled():
app = create_web_api_app()
else:
app = FastAPI(
title="Bedolaga Unified Server",
version=settings.WEB_API_VERSION,
docs_url=docs_config.get("docs_url"),
redoc_url=docs_config.get("redoc_url"),
openapi_url=docs_config.get("openapi_url"),
)
_attach_docs_alias(app, app.docs_url)
return app
def _mount_miniapp_static(app: FastAPI) -> tuple[bool, Path]:
static_path: Path = settings.get_miniapp_static_path()
if not static_path.exists():
logger.debug("Miniapp static path %s does not exist, skipping mount", static_path)
return False, static_path
try:
app.mount("/miniapp/static", StaticFiles(directory=static_path), name="miniapp-static")
logger.info("📦 Miniapp static files mounted at /miniapp/static from %s", static_path)
except RuntimeError as error: # pragma: no cover - defensive guard
logger.warning("Не удалось смонтировать статические файлы миниаппа: %s", error)
return False, static_path
return True, static_path
def create_unified_app(
bot: Bot,
dispatcher: Dispatcher,
payment_service: PaymentService,
*,
enable_telegram_webhook: bool,
) -> FastAPI:
app = _create_base_app()
app.state.bot = bot
app.state.dispatcher = dispatcher
app.state.payment_service = payment_service
payments_router = payments.create_payment_router(bot, payment_service)
if payments_router:
app.include_router(payments_router)
payment_providers_state = {
"tribute": settings.TRIBUTE_ENABLED,
"mulenpay": settings.is_mulenpay_enabled(),
"cryptobot": settings.is_cryptobot_enabled(),
"yookassa": settings.is_yookassa_enabled(),
"pal24": settings.is_pal24_enabled(),
"wata": settings.is_wata_enabled(),
"heleket": settings.is_heleket_enabled(),
}
if enable_telegram_webhook:
telegram_processor = telegram.TelegramWebhookProcessor(
bot=bot,
dispatcher=dispatcher,
queue_maxsize=settings.get_webhook_queue_maxsize(),
worker_count=settings.get_webhook_worker_count(),
enqueue_timeout=settings.get_webhook_enqueue_timeout(),
shutdown_timeout=settings.get_webhook_shutdown_timeout(),
)
app.state.telegram_webhook_processor = telegram_processor
@app.on_event("startup")
async def start_telegram_webhook_processor() -> None: # pragma: no cover - event hook
await telegram_processor.start()
@app.on_event("shutdown")
async def stop_telegram_webhook_processor() -> None: # pragma: no cover - event hook
await telegram_processor.stop()
app.include_router(telegram.create_telegram_router(bot, dispatcher, processor=telegram_processor))
else:
telegram_processor = None
miniapp_mounted, miniapp_path = _mount_miniapp_static(app)
unified_health_path = "/health/unified" if settings.is_web_api_enabled() else "/health"
@app.get(unified_health_path)
async def unified_health() -> JSONResponse:
webhook_path = settings.get_telegram_webhook_path() if enable_telegram_webhook else None
telegram_state = {
"enabled": enable_telegram_webhook,
"running": bool(telegram_processor and telegram_processor.is_running),
"url": settings.get_telegram_webhook_url(),
"path": webhook_path,
"secret_configured": bool(settings.WEBHOOK_SECRET_TOKEN),
"queue_maxsize": settings.get_webhook_queue_maxsize(),
"workers": settings.get_webhook_worker_count(),
}
payment_state = {
"enabled": bool(payments_router),
"providers": payment_providers_state,
}
miniapp_state = {
"mounted": miniapp_mounted,
"path": str(miniapp_path),
}
return JSONResponse(
{
"status": "ok",
"bot_run_mode": settings.get_bot_run_mode(),
"web_api_enabled": settings.is_web_api_enabled(),
"payment_webhooks": payment_state,
"telegram_webhook": telegram_state,
"miniapp_static": miniapp_state,
}
)
return app
+1 -7
View File
@@ -73,16 +73,10 @@ services:
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${WEB_API_PORT:-8080}:8080"
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
- "${CRYPTOBOT_WEBHOOK_PORT:-8083}:8083"
- "${PAL24_WEBHOOK_PORT:-8084}:8084"
- "${WATA_WEBHOOK_PORT:-8085}:8085"
- "${HELEKET_WEBHOOK_PORT:-8086}:8086"
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8081/health\", timeout=5)' || exit 1"]
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8080/health\", timeout=5)' || exit 1"]
interval: 60s
timeout: 10s
retries: 3
+1 -7
View File
@@ -73,16 +73,10 @@ services:
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${WEB_API_PORT:-8080}:8080"
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
- "${CRYPTOBOT_WEBHOOK_PORT:-8083}:8083"
- "${PAL24_WEBHOOK_PORT:-8084}:8084"
- "${WATA_WEBHOOK_PORT:-8085}:8085"
- "${HELEKET_WEBHOOK_PORT:-8086}:8086"
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8081/health\", timeout=5)' || exit 1"]
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8080/health\", timeout=5)' || exit 1"]
interval: 60s
timeout: 10s
retries: 3
+2 -2
View File
@@ -20,7 +20,7 @@ API разворачивается вместе с ботом, использу
| `WEB_API_HOST` | IP/hostname, на котором слушает API. | `0.0.0.0`
| `WEB_API_PORT` | Порт веб-API. | `8080`
| `WEB_API_ALLOWED_ORIGINS` | Список доменов для CORS, через запятую. `*` разрешит всё. | `https://admin.example.com`
| `WEB_API_DOCS_ENABLED` | Включить `/docs` и `/openapi.json`. В проде лучше `false`. | `false`
| `WEB_API_DOCS_ENABLED` | Включить `/docs`, `/doc` (редирект), `/redoc` и `/openapi.json`. В проде лучше `false`. | `false`
| `WEB_API_WORKERS` | Количество воркеров uvicorn. В embed-режиме всегда приводится к `1`. | `1`
| `WEB_API_REQUEST_LOGGING` | Логировать каждый запрос API. | `true`
| `WEB_API_DEFAULT_TOKEN` | Бутстрап-токен, который будет создан при миграции. | `super-secret-token`
@@ -34,7 +34,7 @@ API разворачивается вместе с ботом, использу
Чтобы открыть интерфейс Swagger UI на `/docs`, убедитесь, что одновременно заданы две переменные окружения:
1. `WEB_API_ENABLED=true` — включает само веб-API.
2. `WEB_API_DOCS_ENABLED=true` — публикует `/docs`, `/redoc` и `/openapi.json`.
2. `WEB_API_DOCS_ENABLED=true` — публикует `/docs`, `/doc` (редирект для старых ссылок), `/redoc` и `/openapi.json`.
После изменения значений перезапустите бота. Интерфейс будет доступен по адресу `http://<WEB_API_HOST>:<WEB_API_PORT>/docs`.
+19 -22
View File
@@ -625,23 +625,18 @@ configure_webhook_proxy() {
echo -e "${CYAN}ℹ Используем домен: ${YELLOW}$webhook_domain${NC}" >&2
cat <<EOF
$webhook_domain {
handle /tribute-webhook* {
reverse_proxy localhost:8081
}
handle /cryptobot-webhook* {
reverse_proxy localhost:8081
}
handle /mulenpay-webhook* {
reverse_proxy localhost:8081
}
handle /pal24-webhook* {
reverse_proxy localhost:8084
}
handle /yookassa-webhook* {
reverse_proxy localhost:8082
}
handle /health {
reverse_proxy localhost:8081/health
encode gzip zstd
@config path /app-config.json
header @config Access-Control-Allow-Origin "*"
reverse_proxy localhost:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
transport http {
read_buffer 0
}
}
}
EOF
@@ -665,15 +660,17 @@ configure_miniapp_proxy() {
cat <<EOF
$miniapp_domain {
encode gzip zstd
root * /var/www/remnawave-miniapp
file_server
@config path /app-config.json
header @config Access-Control-Allow-Origin "*"
@redirect path /miniapp/redirect/index.html
redir @redirect /miniapp/redirect/index.html permanent
reverse_proxy /miniapp/* 127.0.0.1:8080 {
reverse_proxy localhost:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
transport http {
read_buffer 0
}
}
}
EOF
+112 -197
View File
@@ -22,11 +22,8 @@ from app.services.payment_verification_service import (
)
from app.database.models import PaymentMethod
from app.services.version_service import version_service
from app.external.webhook_server import WebhookServer
from app.external.heleket_webhook import start_heleket_webhook_server
from app.external.yookassa_webhook import start_yookassa_webhook_server
from app.external.pal24_webhook import start_pal24_webhook_server, Pal24WebhookServer
from app.external.wata_webhook import start_wata_webhook_server
from app.webapi.server import WebAPIServer
from app.webserver.unified_app import create_unified_app
from app.database.universal_migration import run_universal_migration
from app.services.backup_service import backup_service
from app.services.reporting_service import reporting_service
@@ -97,17 +94,16 @@ async def main():
signal.signal(signal.SIGINT, killer.exit_gracefully)
signal.signal(signal.SIGTERM, killer.exit_gracefully)
webhook_server = None
yookassa_server_task = None
wata_server_task = None
heleket_server_task = None
pal24_server: Pal24WebhookServer | None = None
web_app = None
monitoring_task = None
maintenance_task = None
version_check_task = None
polling_task = None
web_api_server = None
telegram_webhook_enabled = False
polling_enabled = True
payment_webhooks_enabled = False
summary_logged = False
try:
@@ -320,92 +316,86 @@ async def main():
stage.warning(f"Ошибка подготовки внешней админки: {error}")
logger.error("❌ Ошибка подготовки внешней админки: %s", error)
webhook_needed = (
settings.TRIBUTE_ENABLED
or settings.is_cryptobot_enabled()
or settings.is_mulenpay_enabled()
bot_run_mode = settings.get_bot_run_mode()
polling_enabled = bot_run_mode in {"polling", "both"}
telegram_webhook_enabled = bot_run_mode in {"webhook", "both"}
payment_webhooks_enabled = any(
[
settings.TRIBUTE_ENABLED,
settings.is_cryptobot_enabled(),
settings.is_mulenpay_enabled(),
settings.is_yookassa_enabled(),
settings.is_pal24_enabled(),
settings.is_wata_enabled(),
settings.is_heleket_enabled(),
]
)
async with timeline.stage(
"Webhook сервисы",
"Единый веб-сервер",
"🌐",
success_message="Webhook сервера настроены",
success_message="Веб-сервер запущен",
) as stage:
if webhook_needed:
enabled_services = []
if settings.TRIBUTE_ENABLED:
enabled_services.append("Tribute")
if settings.is_mulenpay_enabled():
enabled_services.append(settings.get_mulenpay_display_name())
if settings.is_cryptobot_enabled():
enabled_services.append("CryptoBot")
should_start_web_app = (
settings.is_web_api_enabled()
or telegram_webhook_enabled
or payment_webhooks_enabled
or settings.get_miniapp_static_path().exists()
)
webhook_server = WebhookServer(bot)
await webhook_server.start()
stage.log(f"Активированы: {', '.join(enabled_services)}")
stage.success("Webhook сервера запущены")
else:
stage.skip(
f"Tribute, {settings.get_mulenpay_display_name()} и CryptoBot отключены"
if should_start_web_app:
web_app = create_unified_app(
bot,
dp,
payment_service,
enable_telegram_webhook=telegram_webhook_enabled,
)
web_api_server = WebAPIServer(app=web_app)
await web_api_server.start()
base_url = settings.WEBHOOK_URL or f"http://{settings.WEB_API_HOST}:{settings.WEB_API_PORT}"
stage.log(f"Базовый URL: {base_url}")
features: list[str] = []
if settings.is_web_api_enabled():
features.append("админка")
if payment_webhooks_enabled:
features.append("платежные webhook-и")
if telegram_webhook_enabled:
features.append("Telegram webhook")
if settings.get_miniapp_static_path().exists():
features.append("статические файлы миниаппа")
if features:
stage.log("Активные сервисы: " + ", ".join(features))
stage.success("HTTP-сервисы активны")
else:
stage.skip("HTTP-сервисы отключены настройками")
async with timeline.stage(
"YooKassa webhook",
"💳",
success_message="YooKassa webhook запущен",
"Telegram webhook",
"🤖",
success_message="Telegram webhook настроен",
) as stage:
if settings.is_yookassa_enabled():
yookassa_server_task = asyncio.create_task(
start_yookassa_webhook_server(payment_service)
)
stage.log(
f"Endpoint: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}"
)
if telegram_webhook_enabled:
webhook_url = settings.get_telegram_webhook_url()
if not webhook_url:
stage.warning("WEBHOOK_URL не задан, пропускаем настройку webhook")
else:
allowed_updates = dp.resolve_used_update_types()
await bot.set_webhook(
url=webhook_url,
secret_token=settings.WEBHOOK_SECRET_TOKEN,
drop_pending_updates=settings.WEBHOOK_DROP_PENDING_UPDATES,
allowed_updates=allowed_updates,
)
stage.log(f"Webhook установлен: {webhook_url}")
stage.log(f"Allowed updates: {', '.join(sorted(allowed_updates)) if allowed_updates else 'all'}")
stage.success("Telegram webhook активен")
else:
stage.skip("YooKassa отключена настройками")
async with timeline.stage(
"PayPalych webhook",
"💳",
success_message="PayPalych webhook запущен",
) as stage:
if settings.is_pal24_enabled():
pal24_server = await start_pal24_webhook_server(payment_service)
stage.log(
f"Endpoint: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}"
)
else:
stage.skip("PayPalych отключен настройками")
async with timeline.stage(
"WATA webhook",
"💳",
success_message="WATA webhook запущен",
) as stage:
if settings.is_wata_enabled():
wata_server_task = asyncio.create_task(
start_wata_webhook_server(payment_service)
)
stage.log(
f"Endpoint: {settings.WEBHOOK_URL}:{settings.WATA_WEBHOOK_PORT}{settings.WATA_WEBHOOK_PATH}"
)
else:
stage.skip("WATA отключен настройками")
async with timeline.stage(
"Heleket webhook",
"🪙",
success_message="Heleket webhook запущен",
) as stage:
if settings.is_heleket_enabled():
heleket_server_task = asyncio.create_task(
start_heleket_webhook_server(payment_service)
)
stage.log(
f"Endpoint: {settings.WEBHOOK_URL}:{settings.HELEKET_WEBHOOK_PORT}{settings.HELEKET_WEBHOOK_PATH}"
)
else:
stage.skip("Heleket отключен настройками")
stage.skip("Режим webhook отключен")
async with timeline.stage(
"Служба мониторинга",
@@ -447,61 +437,43 @@ async def main():
version_check_task = None
stage.skip("Проверка версий отключена настройками")
async with timeline.stage(
"Административное веб-API",
"🌐",
success_message="Веб-API запущено",
) as stage:
if settings.is_web_api_enabled():
try:
from app.webapi import WebAPIServer
web_api_server = WebAPIServer()
await web_api_server.start()
stage.success(
f"Доступно на http://{settings.WEB_API_HOST}:{settings.WEB_API_PORT}"
)
except Exception as error:
stage.warning(f"Не удалось запустить веб-API: {error}")
logger.error(f"❌ Не удалось запустить веб-API: {error}")
else:
stage.skip("Веб-API отключено")
async with timeline.stage(
"Запуск polling",
"🤖",
success_message="Aiogram polling запущен",
) as stage:
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
stage.log("skip_updates=True")
if polling_enabled:
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
stage.log("skip_updates=True")
else:
polling_task = None
stage.skip("Polling отключен режимом работы")
webhook_lines = []
if webhook_needed:
if settings.TRIBUTE_ENABLED:
webhook_lines.append(
f"Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}"
)
if settings.is_mulenpay_enabled():
webhook_lines.append(
f"{settings.get_mulenpay_display_name()}: "
f"{settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
)
if settings.is_cryptobot_enabled():
webhook_lines.append(
f"CryptoBot: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}"
)
webhook_lines: list[str] = []
base_url = settings.WEBHOOK_URL or f"http://{settings.WEB_API_HOST}:{settings.WEB_API_PORT}"
def _fmt(path: str) -> str:
return f"{base_url}{path if path.startswith('/') else '/' + path}"
telegram_webhook_url = settings.get_telegram_webhook_url()
if telegram_webhook_enabled and telegram_webhook_url:
webhook_lines.append(f"Telegram: {telegram_webhook_url}")
if settings.TRIBUTE_ENABLED:
webhook_lines.append(f"Tribute: {_fmt(settings.TRIBUTE_WEBHOOK_PATH)}")
if settings.is_mulenpay_enabled():
webhook_lines.append(
f"{settings.get_mulenpay_display_name()}: {_fmt(settings.MULENPAY_WEBHOOK_PATH)}"
)
if settings.is_cryptobot_enabled():
webhook_lines.append(f"CryptoBot: {_fmt(settings.CRYPTOBOT_WEBHOOK_PATH)}")
if settings.is_yookassa_enabled():
webhook_lines.append(
f"YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}"
)
webhook_lines.append(f"YooKassa: {_fmt(settings.YOOKASSA_WEBHOOK_PATH)}")
if settings.is_pal24_enabled():
webhook_lines.append(
f"PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}"
)
webhook_lines.append(f"PayPalych: {_fmt(settings.PAL24_WEBHOOK_PATH)}")
if settings.is_wata_enabled():
webhook_lines.append(
f"WATA: {settings.WEBHOOK_URL}:{settings.WATA_WEBHOOK_PORT}{settings.WATA_WEBHOOK_PATH}"
)
webhook_lines.append(f"WATA: {_fmt(settings.WATA_WEBHOOK_PATH)}")
if settings.is_heleket_enabled():
webhook_lines.append(f"Heleket: {_fmt(settings.HELEKET_WEBHOOK_PATH)}")
timeline.log_section(
"Активные webhook endpoints",
@@ -536,39 +508,6 @@ async def main():
while not killer.exit:
await asyncio.sleep(1)
if yookassa_server_task and yookassa_server_task.done():
exception = yookassa_server_task.exception()
if exception:
logger.error(f"YooKassa webhook сервер завершился с ошибкой: {exception}")
logger.info("🔄 Перезапуск YooKassa webhook сервера...")
yookassa_server_task = asyncio.create_task(
start_yookassa_webhook_server(payment_service)
)
if wata_server_task and wata_server_task.done():
exception = wata_server_task.exception()
if exception:
logger.error(f"WATA webhook сервер завершился с ошибкой: {exception}")
logger.info("🔄 Перезапуск WATA webhook сервера...")
if settings.is_wata_enabled():
wata_server_task = asyncio.create_task(
start_wata_webhook_server(payment_service)
)
else:
wata_server_task = None
if heleket_server_task and heleket_server_task.done():
exception = heleket_server_task.exception()
if exception:
logger.error(f"Heleket webhook сервер завершился с ошибкой: {exception}")
logger.info("🔄 Перезапуск Heleket webhook сервера...")
if settings.is_heleket_enabled():
heleket_server_task = asyncio.create_task(
start_heleket_webhook_server(payment_service)
)
else:
heleket_server_task = None
if monitoring_task.done():
exception = monitoring_task.exception()
if exception:
@@ -596,7 +535,7 @@ async def main():
await auto_payment_verification_service.start()
auto_verification_active = auto_payment_verification_service.is_running()
if polling_task.done():
if polling_task and polling_task.done():
exception = polling_task.exception()
if exception:
logger.error(f"Polling завершился с ошибкой: {exception}")
@@ -623,30 +562,6 @@ async def main():
f"Ошибка остановки сервиса автопроверки пополнений: {error}"
)
if yookassa_server_task and not yookassa_server_task.done():
logger.info("ℹ️ Остановка YooKassa webhook сервера...")
yookassa_server_task.cancel()
try:
await yookassa_server_task
except asyncio.CancelledError:
pass
if wata_server_task and not wata_server_task.done():
logger.info("ℹ️ Остановка WATA webhook сервера...")
wata_server_task.cancel()
try:
await wata_server_task
except asyncio.CancelledError:
pass
if heleket_server_task and not heleket_server_task.done():
logger.info("ℹ️ Остановка Heleket webhook сервера...")
heleket_server_task.cancel()
try:
await heleket_server_task
except asyncio.CancelledError:
pass
if monitoring_task and not monitoring_task.done():
logger.info("ℹ️ Остановка службы мониторинга...")
monitoring_service.stop_monitoring()
@@ -656,10 +571,6 @@ async def main():
except asyncio.CancelledError:
pass
if pal24_server:
logger.info("ℹ️ Остановка PayPalych webhook сервера...")
await asyncio.get_running_loop().run_in_executor(None, pal24_server.stop)
if maintenance_task and not maintenance_task.done():
logger.info("ℹ️ Остановка службы техработ...")
await maintenance_service.stop_monitoring()
@@ -703,9 +614,13 @@ async def main():
except asyncio.CancelledError:
pass
if webhook_server:
logger.info("Остановка webhook сервера...")
await webhook_server.stop()
if telegram_webhook_enabled and 'bot' in locals():
logger.info("Снятие Telegram webhook...")
try:
await bot.delete_webhook(drop_pending_updates=False)
logger.info("✅ Telegram webhook удалён")
except Exception as error:
logger.error(f"Ошибка удаления Telegram webhook: {error}")
if web_api_server:
try:
+703
View File
@@ -0,0 +1,703 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#2481cc">
<title>Connecting to VPN...</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
:root {
--tg-theme-bg-color: #ffffff;
--tg-theme-text-color: #000000;
--tg-theme-hint-color: #999999;
--tg-theme-link-color: #2481cc;
--tg-theme-button-color: #2481cc;
--tg-theme-button-text-color: #ffffff;
--tg-theme-secondary-bg-color: #f0f0f0;
--primary: var(--tg-theme-button-color);
--primary-rgb: 36, 129, 204;
--text-primary: var(--tg-theme-text-color);
--text-secondary: var(--tg-theme-hint-color);
--bg-primary: var(--tg-theme-bg-color);
--bg-secondary: var(--tg-theme-secondary-bg-color);
--border-color: rgba(0, 0, 0, 0.08);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
--radius-sm: 8px;
--radius: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--info: #3b82f6;
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg-primary: #0f172a;
--bg-secondary: rgba(30, 41, 59, 0.85);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--border-color: rgba(148, 163, 184, 0.25);
--shadow-sm: 0 2px 8px rgba(2, 6, 23, 0.3);
--shadow-md: 0 4px 16px rgba(2, 6, 23, 0.45);
--shadow-lg: 0 8px 32px rgba(2, 6, 23, 0.55);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
line-height: 1.6;
display: flex;
align-items: center;
justify-content: center;
}
.container {
max-width: 480px;
width: 100%;
margin: 0 auto;
padding: 16px;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes shimmer {
to { transform: translateX(100%); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
}
/* Language Switcher */
.top-bar {
position: fixed;
top: 16px;
right: 16px;
z-index: 100;
display: flex;
gap: 12px;
}
.language-select {
padding: 8px 32px 8px 12px;
border-radius: var(--radius);
border: 2px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
appearance: none;
transition: all 0.3s ease;
position: relative;
min-width: 120px;
}
.language-select:hover {
border-color: var(--primary);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
/* Theme Toggle */
.theme-toggle {
width: 44px;
height: 44px;
border-radius: var(--radius);
border: 2px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.theme-toggle:hover {
border-color: var(--primary);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
/* Header */
.header {
text-align: center;
margin-bottom: 32px;
position: relative;
}
.logo-container {
width: 80px;
height: 80px;
margin: 0 auto 16px;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
border-radius: var(--radius-xl);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-md);
position: relative;
overflow: hidden;
animation: pulse 2s infinite;
}
.logo-container::after {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transform: rotate(45deg);
animation: shimmer 3s infinite;
}
.logo-icon {
font-size: 40px;
color: white;
z-index: 1;
font-weight: 700;
}
.logo {
font-size: 28px;
font-weight: 800;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 8px;
}
.subtitle {
font-size: 15px;
color: var(--text-secondary);
font-weight: 500;
}
/* Main Card */
.card {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 32px;
box-shadow: var(--shadow-sm);
transition: all 0.3s ease;
overflow: hidden;
text-align: center;
}
.card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
/* Error State */
.card.error {
border: 2px solid var(--danger);
background: linear-gradient(135deg, var(--bg-secondary), rgba(239, 68, 68, 0.05));
}
.card.error .logo-container {
background: linear-gradient(135deg, var(--danger), rgba(239, 68, 68, 0.7));
}
/* Loading State */
.loading {
text-align: center;
padding: 24px;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid var(--bg-secondary);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 20px;
}
.loading-text {
font-size: 16px;
color: var(--text-secondary);
font-weight: 500;
margin-bottom: 8px;
}
/* Timer */
.timer-container {
background: var(--bg-primary);
border-radius: var(--radius);
padding: 24px;
margin: 24px 0;
border: 2px solid var(--border-color);
}
.timer-label {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.timer-display {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 16px;
}
.timer-number {
font-size: 48px;
font-weight: 800;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
}
.timer-unit {
font-size: 16px;
color: var(--text-secondary);
font-weight: 500;
}
.progress-track {
width: 100%;
height: 6px;
background: var(--border-color);
border-radius: 3px;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), rgba(var(--primary-rgb), 0.6));
border-radius: 3px;
transition: width 1s linear;
width: 100%;
position: relative;
overflow: hidden;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
animation: shimmer 2s infinite;
}
/* Status Text */
.status-text {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.status-description {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 24px;
}
.error-text {
color: var(--danger);
}
/* Button */
.btn {
width: 100%;
padding: 16px 24px;
border: none;
border-radius: var(--radius);
font-size: 15px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
text-transform: uppercase;
letter-spacing: 0.5px;
text-decoration: none;
}
.btn::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.btn:active::before {
width: 300px;
height: 300px;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.8));
color: white;
box-shadow: var(--shadow-md);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-primary:active {
transform: scale(0.98);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
.btn-icon {
width: 20px;
height: 20px;
stroke-width: 2.5;
}
/* Hidden */
.hidden {
display: none !important;
}
/* Mobile Optimizations */
@media (max-width: 480px) {
.container {
padding: 12px;
}
.timer-number {
font-size: 40px;
}
.logo {
font-size: 24px;
}
}
/* Dark Mode Adjustments */
:root[data-theme="dark"] .timer-container {
background: var(--bg-secondary);
}
:root[data-theme="dark"] .btn-primary {
box-shadow: 0 10px 30px rgba(37, 99, 235, 0.45);
}
</style>
</head>
<body>
<!-- Top Bar -->
<div class="top-bar">
<select id="languageSelect" class="language-select">
<option value="en">🇬🇧 English</option>
<option value="ru">🇷🇺 Русский</option>
</select>
<button class="theme-toggle" id="themeToggle">
<svg class="icon-sun" width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 18a6 6 0 100-12 6 6 0 000 12zM12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<svg class="icon-moon" width="20" height="20" fill="currentColor" viewBox="0 0 24 24" style="display: none;">
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
</svg>
</button>
</div>
<div class="container">
<!-- Header -->
<div class="header animate-in">
<div class="logo-container">
<div class="logo-icon" id="appIcon"></div>
</div>
<div class="logo" data-i18n="app.name">VPN</div>
<div class="subtitle" id="appSubtitle" data-i18n="app.connecting">Connecting to VPN...</div>
</div>
<!-- Main Card -->
<div class="card animate-in" id="mainCard">
<div class="loading" id="loadingState">
<div class="spinner"></div>
<div class="loading-text" id="statusText" data-i18n="status.connecting">Establishing secure connection...</div>
<div class="timer-container" id="timerContainer">
<div class="timer-label" data-i18n="timer.label">Redirecting in</div>
<div class="timer-display">
<span class="timer-number" id="timer">10</span>
<span class="timer-unit" data-i18n="timer.seconds">seconds</span>
</div>
<div class="progress-track">
<div class="progress-fill" id="progressFill"></div>
</div>
</div>
<p class="status-description" data-i18n="status.manual">If nothing happens, click the button below.</p>
</div>
<!-- Error State (hidden by default) -->
<div class="error-state hidden" id="errorState">
<div class="status-text error-text" data-i18n="error.title">Connection Error</div>
<p class="status-description" data-i18n="error.description">The connection link is missing or invalid.</p>
</div>
<!-- Action Button -->
<a class="btn btn-primary" id="actionButton" href="#" target="_blank" rel="noopener">
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span data-i18n="button.connect">Connect to VPN</span>
</a>
</div>
</div>
<script>
// App schemes configuration
const appSchemes = [
{ scheme: 'happ://', id: 'happ', icon: 'H', name: { en: 'Happ', ru: 'Happ' } },
{ scheme: 'flclash://', id: 'flclash', icon: 'F', name: { en: 'FlClash', ru: 'FlClash' } },
{ scheme: 'clash://', id: 'clash-meta', icon: 'C', name: { en: 'Clash Meta', ru: 'Clash Meta' } },
{ scheme: 'sing-box://', id: 'sing-box', icon: 'S', name: { en: 'Sing-box', ru: 'Sing-box' } },
{ scheme: 'v2rayng://', id: 'v2rayng', icon: 'V', name: { en: 'v2rayNG', ru: 'v2rayNG' } },
{ scheme: 'sub://', id: 'shadowrocket', icon: 'R', name: { en: 'Shadowrocket', ru: 'Shadowrocket' } },
{ scheme: 'hiddify://', id: 'hiddify', icon: 'H', name: { en: 'Hiddify', ru: 'Hiddify' } }
];
// Translations
const translations = {
en: {
'app.name': 'VPN',
'app.connecting': 'Connecting to VPN...',
'status.connecting': 'Establishing secure connection...',
'timer.label': 'Redirecting in',
'timer.seconds': 'seconds',
'status.manual': 'If nothing happens, click the button below.',
'button.connect': 'Connect to VPN',
'error.title': 'Connection Error',
'error.description': 'The connection link is missing or invalid. Please contact support.'
},
ru: {
'app.name': 'VPN',
'app.connecting': 'Подключение к VPN...',
'status.connecting': 'Установка безопасного соединения...',
'timer.label': 'Перенаправление через',
'timer.seconds': 'секунд',
'status.manual': 'Если ничего не происходит, нажмите кнопку ниже.',
'button.connect': 'Подключиться к VPN',
'error.title': 'Ошибка подключения',
'error.description': 'Ссылка для подключения отсутствует или недействительна. Обратитесь в поддержку.'
}
};
// Global variables
let currentLanguage = 'en';
let currentTheme = 'light';
let timerValue = 10;
let timerId = null;
let redirectTo = '';
let appInfo = null;
// Initialize
document.addEventListener('DOMContentLoaded', function() {
loadSettings();
// Get redirect URL from URL parameters
const urlParams = new URLSearchParams(window.location.search);
redirectTo = urlParams.get('redirect_to') || '';
// Detect app from URL scheme
if (redirectTo) {
appInfo = appSchemes.find(a => redirectTo.startsWith(a.scheme));
}
updateUI();
if (redirectTo) {
startTimer();
} else {
showError();
}
});
// Load saved settings
function loadSettings() {
const savedTheme = localStorage.getItem('remnawave-miniapp-theme') || 'light';
const savedLang = localStorage.getItem('remnawave-miniapp-language') || getBrowserLanguage();
currentTheme = savedTheme;
currentLanguage = savedLang;
applyTheme(savedTheme);
document.getElementById('languageSelect').value = currentLanguage;
}
// Get browser language
function getBrowserLanguage() {
const lang = navigator.language || navigator.userLanguage;
if (lang.startsWith('ru')) return 'ru';
return 'en';
}
// Apply theme
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
updateThemeToggle();
}
// Update theme toggle icon
function updateThemeToggle() {
const sunIcon = document.querySelector('.icon-sun');
const moonIcon = document.querySelector('.icon-moon');
if (currentTheme === 'dark') {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
} else {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
}
}
// Toggle theme
document.getElementById('themeToggle').addEventListener('click', function() {
currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
localStorage.setItem('remnawave-miniapp-theme', currentTheme);
applyTheme(currentTheme);
});
// Change language
document.getElementById('languageSelect').addEventListener('change', function(e) {
currentLanguage = e.target.value;
localStorage.setItem('remnawave-miniapp-language', currentLanguage);
updateUI();
});
// Get translation
function t(key) {
return translations[currentLanguage]?.[key] || translations.en[key] || key;
}
// Update UI with translations
function updateUI() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
// Update app-specific info
if (appInfo && redirectTo) {
const appName = appInfo.name[currentLanguage] || appInfo.name.en;
document.getElementById('appIcon').textContent = appInfo.icon;
document.getElementById('appSubtitle').textContent = `${t('app.connecting').replace('VPN', appName)}`;
const statusText = document.getElementById('statusText');
if (statusText) {
statusText.textContent = `${t('status.connecting').replace('...', '')} ${appName}...`;
}
}
// Update action button
const actionButton = document.getElementById('actionButton');
if (actionButton && redirectTo) {
actionButton.href = redirectTo;
}
}
// Start countdown timer
function startTimer() {
if (!redirectTo || timerId) return;
const timerEl = document.getElementById('timer');
const progressEl = document.getElementById('progressFill');
const totalTime = 10;
timerEl.textContent = timerValue;
timerId = setInterval(() => {
timerValue--;
timerEl.textContent = timerValue;
// Update progress bar
const progress = ((totalTime - timerValue) / totalTime) * 100;
progressEl.style.width = `${100 - progress}%`;
if (timerValue <= 0) {
clearInterval(timerId);
window.location.href = redirectTo;
}
}, 1000);
}
// Show error state
function showError() {
document.getElementById('loadingState').classList.add('hidden');
document.getElementById('errorState').classList.remove('hidden');
document.getElementById('actionButton').classList.add('hidden');
document.getElementById('mainCard').classList.add('error');
document.getElementById('appIcon').textContent = '!';
// Update subtitle
document.getElementById('appSubtitle').textContent = t('error.title');
}
</script>
</body>
</html>
-137
View File
@@ -1,137 +0,0 @@
"""Тестирование хендлеров WebhookServer без запуска реального сервера."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Tuple
import sys
from unittest.mock import AsyncMock
import pytest
from aiohttp.test_utils import make_mocked_request
from aiohttp import web
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from app.config import settings # noqa: E402
from app.external.webhook_server import WebhookServer # noqa: E402
class DummyBot:
async def send_message(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover - уведомления не проверяем
return None
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
@pytest.fixture
def webhook_server(monkeypatch: pytest.MonkeyPatch) -> Tuple[WebhookServer, AsyncMock, AsyncMock]:
monkeypatch.setattr(settings, "TRIBUTE_WEBHOOK_PATH", "/tribute", raising=False)
monkeypatch.setattr(settings, "MULENPAY_WEBHOOK_PATH", "/mulen", raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_PATH", "/cryptobot", raising=False)
monkeypatch.setattr(settings, "MULENPAY_SECRET_KEY", "mulen-secret", raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "", raising=False)
monkeypatch.setattr(type(settings), "is_mulenpay_enabled", lambda self: True, raising=False)
monkeypatch.setattr(type(settings), "is_cryptobot_enabled", lambda self: True, raising=False)
server = WebhookServer(DummyBot())
tribute_mock = AsyncMock()
tribute_mock.process_webhook = AsyncMock(return_value={"status": "ok"})
server.tribute_service = tribute_mock
payment_mock = AsyncMock()
payment_mock.process_mulenpay_callback = AsyncMock(return_value=True)
payment_mock.process_cryptobot_webhook = AsyncMock(return_value=True)
monkeypatch.setattr("app.external.webhook_server.PaymentService", lambda *args, **kwargs: payment_mock)
monkeypatch.setattr("app.services.payment_service.PaymentService", lambda *args, **kwargs: payment_mock)
server._verify_mulenpay_signature = lambda request, raw: True # type: ignore[attr-defined]
class DummyDB:
async def commit(self) -> None: # pragma: no cover - не проверяем транзакции
return None
async def fake_get_db():
yield DummyDB()
monkeypatch.setattr("app.external.webhook_server.get_db", fake_get_db)
class DummySessionManager:
def __init__(self) -> None:
self.session = DummyDB()
async def __aenter__(self) -> DummyDB:
return self.session
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
monkeypatch.setattr("app.database.database.AsyncSessionLocal", lambda: DummySessionManager())
return server, tribute_mock, payment_mock
def _mock_request(method: str, path: str, body: dict[str, Any], headers: dict[str, str] | None = None) -> AsyncMock:
request = AsyncMock(spec=web.Request)
request.method = method
request.path = path
request.headers = headers or {}
request.read.return_value = json.dumps(body).encode("utf-8")
return request
@pytest.mark.anyio("asyncio")
async def test_health_endpoint(webhook_server: Tuple[WebhookServer, AsyncMock, AsyncMock]) -> None:
server, _, _ = webhook_server
request = make_mocked_request("GET", "/health")
response = await server._health_check(request)
assert response.status == 200
data = json.loads(response.text)
assert data["status"] == "ok"
assert data["service"] == "payment-webhooks"
@pytest.mark.anyio("asyncio")
async def test_tribute_webhook_success(monkeypatch: pytest.MonkeyPatch, webhook_server: Tuple[WebhookServer, AsyncMock, AsyncMock]) -> None:
server, tribute_mock, _ = webhook_server
monkeypatch.setattr(settings, "TRIBUTE_API_KEY", "key", raising=False)
class FakeTributeAPI:
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
return True
monkeypatch.setattr("app.external.tribute.TributeService", FakeTributeAPI)
request = _mock_request("POST", "/tribute", {"event_type": "payment", "status": "paid"}, headers={"trbt-signature": "sig"})
response = await server._tribute_webhook_handler(request)
assert response.status == 200
assert tribute_mock.process_webhook.await_count == 1
@pytest.mark.anyio("asyncio")
async def test_mulenpay_webhook_success(webhook_server: Tuple[WebhookServer, AsyncMock, AsyncMock]) -> None:
server, _, payment_mock = webhook_server
request = _mock_request("POST", "/mulen", {"uuid": "uuid", "payment_status": "success"})
response = await server._mulenpay_webhook_handler(request)
assert response.status == 200
payment_mock.process_mulenpay_callback.assert_awaited_once()
@pytest.mark.anyio("asyncio")
async def test_cryptobot_webhook_success(webhook_server: Tuple[WebhookServer, AsyncMock, AsyncMock]) -> None:
server, _, payment_mock = webhook_server
request = _mock_request(
"POST",
"/cryptobot",
{"update_type": "invoice_paid", "payload": {"invoice_id": 1}},
)
response = await server._cryptobot_webhook_handler(request)
assert response.status == 200
payment_mock.process_cryptobot_webhook.assert_awaited_once()
+191
View File
@@ -0,0 +1,191 @@
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from starlette.requests import Request
from app.config import settings
from app.webserver.payments import create_payment_router
class DummyBot:
pass
@pytest.fixture(autouse=True)
def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "TRIBUTE_ENABLED", False, raising=False)
monkeypatch.setattr(settings, "TRIBUTE_API_KEY", None, raising=False)
monkeypatch.setattr(settings, "TRIBUTE_WEBHOOK_PATH", "/tribute", raising=False)
monkeypatch.setattr(settings, "MULENPAY_WEBHOOK_PATH", "/mulen", raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_ENABLED", False, raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_API_TOKEN", None, raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_PATH", "/cryptobot", raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", None, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", False, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_PATH", "/yookassa", raising=False)
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", None, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop", raising=False)
monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "key", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_URL", "http://test", raising=False)
def _get_route(router, path: str, method: str = "POST"):
for route in router.routes:
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
return route
raise AssertionError(f"Route {path} with method {method} not found")
def _build_request(path: str, body: bytes, headers: dict[str, str]) -> Request:
scope = {
"type": "http",
"asgi": {"version": "3.0"},
"method": "POST",
"path": path,
"headers": [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in headers.items()],
}
async def receive() -> dict:
return {"type": "http.request", "body": body, "more_body": False}
return Request(scope, receive)
@pytest.mark.anyio
async def test_tribute_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "TRIBUTE_ENABLED", True, raising=False)
process_mock = AsyncMock(return_value={"status": "ok"})
class StubTributeService:
def __init__(self, *_args, **_kwargs):
pass
async def process_webhook(self, payload: str): # type: ignore[override]
return await process_mock(payload)
class StubTributeAPI:
@staticmethod
def verify_webhook_signature(payload: str, signature: str) -> bool: # noqa: D401 - test stub
return True
monkeypatch.setattr("app.webserver.payments.TributeService", StubTributeService)
monkeypatch.setattr("app.webserver.payments.TributeAPI", StubTributeAPI)
router = create_payment_router(DummyBot(), SimpleNamespace())
assert router is not None
route = _get_route(router, settings.TRIBUTE_WEBHOOK_PATH)
request = _build_request(
settings.TRIBUTE_WEBHOOK_PATH,
body=json.dumps({"event": "payment"}).encode("utf-8"),
headers={"trbt-signature": "sig"},
)
response = await route.endpoint(request)
assert response.status_code == 200
assert json.loads(response.body.decode("utf-8"))["status"] == "ok"
process_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_yookassa_invalid_signature(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", "secret", raising=False)
class StubHandler:
@staticmethod
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool: # noqa: D401
return False
monkeypatch.setattr("app.webserver.payments.YooKassaWebhookHandler", StubHandler)
router = create_payment_router(DummyBot(), SimpleNamespace())
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
headers={"Signature": "bad"},
)
response = await route.endpoint(request)
assert response.status_code == 401
@pytest.mark.anyio
async def test_yookassa_missing_signature(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", "secret", raising=False)
router = create_payment_router(DummyBot(), SimpleNamespace())
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
headers={},
)
response = await route.endpoint(request)
assert response.status_code == 401
payload = json.loads(response.body.decode("utf-8"))
assert payload["reason"] == "missing_signature"
@pytest.mark.anyio
async def test_cryptobot_missing_signature(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "CRYPTOBOT_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_API_TOKEN", "token", raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "secret", raising=False)
router = create_payment_router(DummyBot(), SimpleNamespace())
assert router is not None
route = _get_route(router, settings.CRYPTOBOT_WEBHOOK_PATH)
request = _build_request(
settings.CRYPTOBOT_WEBHOOK_PATH,
body=json.dumps({"test": "value"}).encode("utf-8"),
headers={},
)
response = await route.endpoint(request)
assert response.status_code == 401
payload = json.loads(response.body.decode("utf-8"))
assert payload["reason"] == "missing_signature"
@pytest.mark.anyio
async def test_cryptobot_invalid_signature(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "CRYPTOBOT_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_API_TOKEN", "token", raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "secret", raising=False)
class StubCryptoBotService:
@staticmethod
def verify_webhook_signature(body: str, signature: str) -> bool: # noqa: D401 - test stub
return False
monkeypatch.setattr("app.external.cryptobot.CryptoBotService", StubCryptoBotService)
router = create_payment_router(DummyBot(), SimpleNamespace())
assert router is not None
route = _get_route(router, settings.CRYPTOBOT_WEBHOOK_PATH)
request = _build_request(
settings.CRYPTOBOT_WEBHOOK_PATH,
body=json.dumps({"test": "value"}).encode("utf-8"),
headers={"Crypto-Pay-API-Signature": "sig"},
)
response = await route.endpoint(request)
assert response.status_code == 401
+338
View File
@@ -0,0 +1,338 @@
import json
from typing import Any
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from app.config import settings
from app.webserver.telegram import (
TelegramWebhookProcessor,
create_telegram_router,
)
@pytest.fixture(autouse=True)
def reset_webhook_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "WEBHOOK_PATH", "/telegram-webhook", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_SECRET_TOKEN", "", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_URL", None, raising=False)
monkeypatch.setattr(settings, "BOT_RUN_MODE", "webhook", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_MAX_QUEUE_SIZE", 8, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_WORKERS", 1, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_ENQUEUE_TIMEOUT", 0.0, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_WORKER_SHUTDOWN_TIMEOUT", 1.0, raising=False)
def _get_route(router, path: str, method: str = "POST"):
for route in router.routes:
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
return route
raise AssertionError(f"Route {path} with method {method} not found")
def _build_request(path: str, body: bytes, headers: dict[str, str] | None = None) -> Request:
scope = {
"type": "http",
"asgi": {"version": "3.0"},
"method": "POST",
"path": path,
"headers": [
(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in (headers or {}).items()
],
}
async def receive() -> dict[str, Any]:
return {"type": "http.request", "body": body, "more_body": False}
return Request(scope, receive)
def _webhook_path() -> str:
return settings.get_telegram_webhook_path()
@pytest.mark.anyio
async def test_webhook_without_secret() -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
sample_update = {
"update_id": 123,
"message": {
"message_id": 10,
"date": 1715700000,
"chat": {"id": 456, "type": "private"},
"text": "ping",
},
}
router = create_telegram_router(bot, dispatcher)
path = _webhook_path()
route = _get_route(router, path)
request = _build_request(path, json.dumps(sample_update).encode("utf-8"))
response = await route.endpoint(request)
assert response.status_code == 200
dispatcher.feed_update.assert_awaited_once()
args, _kwargs = dispatcher.feed_update.await_args
assert args[0] is bot
@pytest.mark.anyio
async def test_webhook_with_secret(monkeypatch: pytest.MonkeyPatch) -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
monkeypatch.setattr(settings, "WEBHOOK_SECRET_TOKEN", "super-secret", raising=False)
sample_update = {
"update_id": 321,
"message": {
"message_id": 20,
"date": 1715700000,
"chat": {"id": 789, "type": "private"},
"text": "pong",
},
}
router = create_telegram_router(bot, dispatcher)
path = _webhook_path()
route = _get_route(router, path)
request = _build_request(
path,
json.dumps(sample_update).encode("utf-8"),
headers={"X-Telegram-Bot-Api-Secret-Token": "super-secret"},
)
response = await route.endpoint(request)
assert response.status_code == 200
dispatcher.feed_update.assert_awaited_once()
@pytest.mark.anyio
async def test_webhook_secret_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
monkeypatch.setattr(settings, "WEBHOOK_SECRET_TOKEN", "expected", raising=False)
router = create_telegram_router(bot, dispatcher)
path = _webhook_path()
route = _get_route(router, path)
request = _build_request(
path,
json.dumps({"update_id": 1}).encode("utf-8"),
headers={"X-Telegram-Bot-Api-Secret-Token": "wrong"},
)
with pytest.raises(HTTPException) as exc:
await route.endpoint(request)
assert exc.value.status_code == 401
dispatcher.feed_update.assert_not_called()
@pytest.mark.anyio
async def test_webhook_invalid_payload() -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
router = create_telegram_router(bot, dispatcher)
path = _webhook_path()
route = _get_route(router, path)
request = _build_request(path, b"not-json")
with pytest.raises(HTTPException) as exc:
await route.endpoint(request)
assert exc.value.status_code == 400
dispatcher.feed_update.assert_not_called()
@pytest.mark.anyio
async def test_webhook_invalid_content_type() -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
sample_update = {
"update_id": 123,
"message": {
"message_id": 10,
"date": 1715700000,
"chat": {"id": 456, "type": "private"},
"text": "ping",
},
}
router = create_telegram_router(bot, dispatcher)
path = _webhook_path()
route = _get_route(router, path)
request = _build_request(
path,
json.dumps(sample_update).encode("utf-8"),
headers={"Content-Type": "text/plain"},
)
with pytest.raises(HTTPException) as exc:
await route.endpoint(request)
assert exc.value.status_code == 415
dispatcher.feed_update.assert_not_called()
@pytest.mark.anyio
async def test_webhook_uses_processor() -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
processor = TelegramWebhookProcessor(
bot=bot,
dispatcher=dispatcher,
queue_maxsize=1,
worker_count=0,
enqueue_timeout=0.0,
shutdown_timeout=1.0,
)
await processor.start()
sample_update = {
"update_id": 999,
"message": {
"message_id": 77,
"date": 1715700000,
"chat": {"id": 111, "type": "private"},
"text": "processor",
},
}
router = create_telegram_router(bot, dispatcher, processor=processor)
path = _webhook_path()
route = _get_route(router, path)
request = _build_request(path, json.dumps(sample_update).encode("utf-8"))
response = await route.endpoint(request)
assert response.status_code == 200
dispatcher.feed_update.assert_not_awaited()
assert processor.is_running
await processor.stop()
@pytest.mark.anyio
async def test_webhook_processor_overloaded() -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
processor = TelegramWebhookProcessor(
bot=bot,
dispatcher=dispatcher,
queue_maxsize=1,
worker_count=0,
enqueue_timeout=0.0,
shutdown_timeout=1.0,
)
await processor.start()
router = create_telegram_router(bot, dispatcher, processor=processor)
path = _webhook_path()
route = _get_route(router, path)
request_payload = json.dumps({"update_id": 1}).encode("utf-8")
request = _build_request(path, request_payload)
await route.endpoint(request)
with pytest.raises(HTTPException) as exc:
await route.endpoint(request)
assert exc.value.status_code == 503
assert exc.value.detail == "webhook_queue_full"
dispatcher.feed_update.assert_not_called()
await processor.stop()
@pytest.mark.anyio
async def test_webhook_processor_not_running() -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
processor = TelegramWebhookProcessor(
bot=bot,
dispatcher=dispatcher,
queue_maxsize=1,
worker_count=1,
enqueue_timeout=0.0,
shutdown_timeout=1.0,
)
router = create_telegram_router(bot, dispatcher, processor=processor)
path = _webhook_path()
route = _get_route(router, path)
request = _build_request(path, json.dumps({"update_id": 5}).encode("utf-8"))
with pytest.raises(HTTPException) as exc:
await route.endpoint(request)
assert exc.value.status_code == 503
assert exc.value.detail == "webhook_processor_unavailable"
dispatcher.feed_update.assert_not_called()
@pytest.mark.anyio
async def test_webhook_path_normalization(monkeypatch: pytest.MonkeyPatch) -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
monkeypatch.setattr(settings, "WEBHOOK_PATH", " telegram/webhook ", raising=False)
router = create_telegram_router(bot, dispatcher)
normalized_path = settings.get_telegram_webhook_path()
assert normalized_path == "/telegram/webhook"
route = _get_route(router, normalized_path)
request = _build_request(normalized_path, json.dumps({"update_id": 7}).encode("utf-8"))
response = await route.endpoint(request)
assert response.status_code == 200
dispatcher.feed_update.assert_awaited_once()
@pytest.mark.anyio
async def test_health_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
monkeypatch.setattr(settings, "WEBHOOK_URL", "https://example.com", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_PATH", "/custom", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_MAX_QUEUE_SIZE", 42, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_WORKERS", 2, raising=False)
router = create_telegram_router(bot, dispatcher)
route = _get_route(router, "/health/telegram-webhook", method="GET")
response = await route.endpoint()
assert response.status_code == 200
payload = json.loads(response.body.decode("utf-8"))
assert payload["status"] == "ok"
assert payload["mode"] == settings.get_bot_run_mode()
assert payload["path"] == "/custom"
assert payload["webhook_configured"] is True
assert payload["queue_maxsize"] == 42
assert payload["workers"] == 2
+148
View File
@@ -0,0 +1,148 @@
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI, status
from app.config import settings
from app.services.payment_service import PaymentService
# ensure backup directory exists before importing the unified app to avoid side effects during module import
_backup_dir = Path("data/backups")
_backup_dir.mkdir(parents=True, exist_ok=True)
from app.webserver.unified_app import create_unified_app
@pytest.mark.anyio
async def test_unified_app_health_reports_features(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
bot = AsyncMock()
dispatcher = SimpleNamespace(feed_update=AsyncMock())
payment_service = AsyncMock(spec=PaymentService)
miniapp_static_dir = tmp_path / "miniapp"
miniapp_static_dir.mkdir()
monkeypatch.setattr(settings, "WEB_API_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "TRIBUTE_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_URL", "https://hooks.example.com", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_PATH", "/telegram-webhook", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_SECRET_TOKEN", "super-secret", raising=False)
monkeypatch.setattr(settings, "BOT_RUN_MODE", "webhook", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_MAX_QUEUE_SIZE", 8, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_WORKERS", 1, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_ENQUEUE_TIMEOUT", 0.0, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_WORKER_SHUTDOWN_TIMEOUT", 1.0, raising=False)
monkeypatch.setattr(settings, "MINIAPP_STATIC_PATH", str(miniapp_static_dir), raising=False)
app = create_unified_app(
bot,
dispatcher, # type: ignore[arg-type]
payment_service,
enable_telegram_webhook=True,
)
health_route = next(
route
for route in app.routes
if getattr(route, "endpoint", None) and getattr(route.endpoint, "__name__", "") == "unified_health"
)
assert getattr(health_route, "path", None) == "/health/unified"
await app.router.startup()
try:
response = await health_route.endpoint() # type: ignore[func-returns-value]
finally:
await app.router.shutdown()
payload = json.loads(response.body.decode("utf-8")) # type: ignore[attr-defined]
assert payload["status"] == "ok"
assert payload["web_api_enabled"] is True
assert payload["bot_run_mode"] == "webhook"
assert payload["telegram_webhook"]["enabled"] is True
assert payload["telegram_webhook"]["running"] is True
assert payload["telegram_webhook"]["path"] == "/telegram-webhook"
assert payload["telegram_webhook"]["secret_configured"] is True
assert payload["payment_webhooks"]["enabled"] is True
assert payload["payment_webhooks"]["providers"]["tribute"] is True
assert payload["miniapp_static"]["mounted"] is True
assert payload["miniapp_static"]["path"].endswith("miniapp")
def _build_unified_app(monkeypatch: pytest.MonkeyPatch, docs_enabled: bool) -> FastAPI:
bot = AsyncMock()
dispatcher = SimpleNamespace(feed_update=AsyncMock())
payment_service = AsyncMock(spec=PaymentService)
monkeypatch.setattr(settings, "WEB_API_ENABLED", False, raising=False)
monkeypatch.setattr(settings, "WEB_API_DOCS_ENABLED", docs_enabled, raising=False)
monkeypatch.setattr(settings, "MINIAPP_STATIC_PATH", "miniapp", raising=False)
return create_unified_app(
bot,
dispatcher, # type: ignore[arg-type]
payment_service,
enable_telegram_webhook=False,
)
@pytest.mark.anyio
async def test_unified_app_health_path_without_admin(monkeypatch: pytest.MonkeyPatch) -> None:
bot = AsyncMock()
dispatcher = SimpleNamespace(feed_update=AsyncMock())
payment_service = AsyncMock(spec=PaymentService)
monkeypatch.setattr(settings, "WEB_API_ENABLED", False, raising=False)
monkeypatch.setattr(settings, "MINIAPP_STATIC_PATH", "miniapp", raising=False)
app = create_unified_app(
bot,
dispatcher, # type: ignore[arg-type]
payment_service,
enable_telegram_webhook=False,
)
health_route = next(
route
for route in app.routes
if getattr(route, "endpoint", None) and getattr(route.endpoint, "__name__", "") == "unified_health"
)
assert getattr(health_route, "path", None) == "/health"
def test_unified_app_docs_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
app = _build_unified_app(monkeypatch, docs_enabled=False)
assert app.docs_url is None
assert app.redoc_url is None
assert app.openapi_url is None
registered_paths = {getattr(route, "path", None) for route in app.routes}
assert "/doc" not in registered_paths
@pytest.mark.anyio
async def test_unified_app_docs_enabled_with_alias(monkeypatch: pytest.MonkeyPatch) -> None:
app = _build_unified_app(monkeypatch, docs_enabled=True)
assert app.docs_url == "/docs"
assert app.redoc_url == "/redoc"
assert app.openapi_url == "/openapi.json"
alias_route = next(
(route for route in app.routes if getattr(route, "path", None) == "/doc"),
None,
)
assert alias_route is not None
assert getattr(alias_route, "include_in_schema", True) is False
response = await alias_route.endpoint() # type: ignore[func-returns-value]
assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT
assert response.headers["location"] == "/docs"