mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-21 03:40:55 +00:00
Merge pull request #1747 from Fr1ngg/dev4
Бот на Вебхуке Без смс и регистрации
This commit is contained in:
22
.env.example
22
.env.example
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
663
README.md
663
README.md
@@ -122,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:
|
||||
@@ -158,178 +190,148 @@ 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
|
||||
bot.example.com {
|
||||
encode gzip zstd
|
||||
|
||||
upstream remnawave_bot_api {
|
||||
server remnawave_bot:8080;
|
||||
}
|
||||
@config path /app-config.json
|
||||
header @config Access-Control-Allow-Origin "*"
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name hooks.example.com;
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
ssl_certificate /etc/ssl/private/hooks.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/hooks.privkey.pem;
|
||||
####
|
||||
|
||||
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; }
|
||||
Либо вместо `remnawave_bot:8080` используйте `localhost:8080`
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name miniapp.example.com;
|
||||
### 6. Пример nginx-конфига
|
||||
|
||||
ssl_certificate /etc/ssl/private/miniapp.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/miniapp.privkey.pem;
|
||||
```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
|
||||
|
||||
root /var/www/remnawave-miniapp;
|
||||
index index.html;
|
||||
networks:
|
||||
bot_network:
|
||||
external: true
|
||||
```
|
||||
|
||||
location /miniapp/ {
|
||||
proxy_pass http://remnawave_bot_api/miniapp/;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
`nginx.conf`:
|
||||
|
||||
```nginx
|
||||
events {}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
sendfile on;
|
||||
|
||||
upstream remnawave_bot_unified {
|
||||
server remnawave_bot:8080;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name bot.example.com;
|
||||
|
||||
ssl_certificate /etc/ssl/private/bot.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/bot.privkey.pem;
|
||||
|
||||
client_max_body_size 32m;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
####
|
||||
|
||||
Либо вместо `remnawave_bot:8080` используйте `localhost:8080`
|
||||
|
||||
Рекомендации:
|
||||
|
||||
- Откройте входящие 80/443 в фаерволе.
|
||||
- Если используете Cloudflare/анти-DDoS, разрешите методы `POST` и заголовок `X-Telegram-Bot-Api-Secret-Token`.
|
||||
- После развёртывания перезапустите бот (`make reload`), чтобы он заново зарегистрировал webhook.
|
||||
|
||||
---
|
||||
|
||||
@@ -340,19 +342,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`.
|
||||
|
||||
### 📊 Статус серверов в главном меню
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
@@ -463,211 +477,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>
|
||||
|
||||
---
|
||||
|
||||
#### ⭐ Функционал
|
||||
@@ -698,6 +507,7 @@ LOG_FILE=logs/bot.log
|
||||
- 🔔 Уведомления об истечении и автоконверсия
|
||||
- 💎 Автовыдача бонусов за кампании и инвайты
|
||||
- 🛡️ Контроль обязательной подписки на канал (отключает подписку при отписке)
|
||||
- 💎 Автовыдача сквада из пула выбранных
|
||||
|
||||
💰 **Платежи и баланс**
|
||||
- ⭐ Telegram Stars
|
||||
@@ -728,7 +538,7 @@ LOG_FILE=logs/bot.log
|
||||
- 💬 Быстрые ссылки на поддержку
|
||||
|
||||
🧩 **Бонусы и промо**
|
||||
- 🎫 Промокоды на деньги, дни, триал подписку
|
||||
- 🎫 Промокоды на деньги, дни, триал подписку, промогруппу
|
||||
- 🎁 **Персональные промо-предложения** от админов
|
||||
- 💰 **Тестовый доступ к серверам** через промо-акции
|
||||
- 💸 **Автоматические скидки** при оплате и автопродлении
|
||||
@@ -737,7 +547,7 @@ LOG_FILE=logs/bot.log
|
||||
- 🔗 Генерация реферальных ссылок и QR кодов
|
||||
|
||||
💎 **Промо-группы и скидки**
|
||||
- 🏷️ **Система промогрупп** с индивидуальными скидками
|
||||
- 🏷️ **Система промогрупп** с индивидуальными скидками с приоритетами
|
||||
- 💰 Скидки на серверы, трафик и устройства
|
||||
- 📊 **Скидочные уровни за траты** - прозрачная система лояльности
|
||||
- 📈 Автоматическое повышение уровня при достижении порога
|
||||
@@ -804,7 +614,6 @@ LOG_FILE=logs/bot.log
|
||||
- 💬 Автоматические сообщения о задолженностях
|
||||
|
||||
🧰 **Обслуживание и DevOps**
|
||||
- 🛠️ `install_bot.sh` - **интерактивное меню управления**
|
||||
- 🚧 Ручной и авто-режим техработ
|
||||
- 🗒️ Просмотр системных логов и health-check
|
||||
- 🔄 **Автосинхронизация Remnawave** по расписанию и при старте бота
|
||||
@@ -845,8 +654,9 @@ LOG_FILE=logs/bot.log
|
||||
- 🧠 **Асинхронная архитектура** - aiogram 3, PostgreSQL/SQLite, Redis и очереди задач
|
||||
- 🌐 **Мультиязычность** - локализации RU/EN, быстрый выбор языка
|
||||
- 📦 **Интеграция с Remnawave API** - автоматическое создание пользователей и синхронизация
|
||||
- 🔄 **Миграция сквадов** - массовый перенос пользователей между серверами
|
||||
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
|
||||
- 🧾 **История операций** - хранение всех транзакций и действий для аудита
|
||||
- 💸 **Сервис автопрвоерки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
|
||||
|
||||
### 🌐 Веб-API и мини-приложение
|
||||
|
||||
@@ -854,7 +664,7 @@ LOG_FILE=logs/bot.log
|
||||
- 🔑 **Управление API-ключами** - выпуск, отзыв, реактивация токенов
|
||||
- 🛰️ **Mini App** - полноценный личный кабинет внутри Telegram
|
||||
- 💳 **Интегрированные платежи** в Mini App (Stars, Pal24, YooKassa, WATA)
|
||||
- 🧭 **App Config** - централизованная раздача ссылок на клиенты
|
||||
- 🧭 **Единый стандартный app-config.json** - централизованная раздача ссылок на клиенты
|
||||
- 🪝 **Платёжные вебхуки** - встроенные серверы для всех платёжных систем
|
||||
- 📡 **Мониторинг серверов** - REST-эндпоинты для просмотра нод и статистики
|
||||
|
||||
@@ -947,9 +757,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
|
||||
@@ -984,7 +794,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 в платежных системах |
|
||||
@@ -1068,7 +878,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)** - защита панели
|
||||
@@ -1159,28 +969,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**
|
||||
@@ -1224,66 +1017,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">
|
||||
|
||||

|
||||

|
||||

|
||||
@@ -1302,8 +1037,6 @@ docker stats
|
||||
|
||||
## 🎯 Ключевые особенности в цифрах
|
||||
|
||||
<div align="center">
|
||||
|
||||
| Метрика | Значение |
|
||||
|---------|----------|
|
||||
| 💳 **Платёжных систем** | 8 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, WATA) |
|
||||
@@ -1315,8 +1048,6 @@ docker stats
|
||||
| 🛡️ **Методов авторизации** | 4 (API Key, Bearer, Basic Auth, eGames) |
|
||||
| 🗄️ **Способов хранения** | 2 (PostgreSQL, SQLite) с автовыбором |
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🔥 Почему выбирают Bedolaga?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
app/webserver/__init__.py
Normal file
11
app/webserver/__init__.py
Normal 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
app/webserver/payments.py
Normal file
596
app/webserver/payments.py
Normal 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
app/webserver/telegram.py
Normal file
250
app/webserver/telegram.py
Normal 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
app/webserver/unified_app.py
Normal file
165
app/webserver/unified_app.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
309
main.py
309
main.py
@@ -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:
|
||||
|
||||
137
tests/external/test_webhook_server.py
vendored
137
tests/external/test_webhook_server.py
vendored
@@ -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
tests/webserver/test_payments.py
Normal file
191
tests/webserver/test_payments.py
Normal 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
tests/webserver/test_telegram.py
Normal file
338
tests/webserver/test_telegram.py
Normal 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
tests/webserver/test_unified_app.py
Normal file
148
tests/webserver/test_unified_app.py
Normal 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"
|
||||
Reference in New Issue
Block a user